Files
sublime-music/sublime/app.py
2020-05-08 11:30:23 -06:00

1135 lines
42 KiB
Python

import logging
import math
import os
import random
import sys
from concurrent.futures import Future
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
try:
import osxmmkeys
tap_imported = True
except Exception:
tap_imported = False
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk
try:
gi.require_version("Notify", "0.7")
from gi.repository import Notify
glib_notify_exists = True
except Exception:
# I really don't care what kind of exception it is, all that matters is the
# import failed for some reason.
logging.warning(
"Unable to import Notify from GLib. Notifications will be disabled."
)
glib_notify_exists = False
from .adapters import AdapterManager
from .adapters.api_objects import Playlist
from .cache_manager import CacheManager
from .config import AppConfiguration, ReplayGainType
from .dbus import dbus_propagate, DBusManager
from .players import ChromecastPlayer, MPVPlayer, PlayerEvent
from .server.api_objects import Child, Directory
from .ui.configure_servers import ConfigureServersDialog
from .ui.main import MainWindow
from .ui.settings import SettingsDialog
from .ui.state import RepeatType
class SublimeMusicApp(Gtk.Application):
def __init__(self, config_file: Path):
super().__init__(application_id="com.sumnerevans.sublimemusic")
if glib_notify_exists:
Notify.init("Sublime Music")
self.window: Optional[Gtk.Window] = None
self.app_config = AppConfiguration.load_from_file(config_file)
self.player = None
self.dbus_manager: Optional[DBusManager] = None
self.connect("shutdown", self.on_app_shutdown)
def do_startup(self):
Gtk.Application.do_startup(self)
def add_action(name: str, fn: Callable, parameter_type: str = None):
"""Registers an action with the application."""
if type(parameter_type) == str:
parameter_type = GLib.VariantType(parameter_type)
action = Gio.SimpleAction.new(name, parameter_type)
action.connect("activate", fn)
self.add_action(action)
# Add action for menu items.
add_action("configure-servers", self.on_configure_servers)
add_action("settings", self.on_settings)
# Add actions for player controls
add_action("play-pause", self.on_play_pause)
add_action("next-track", self.on_next_track)
add_action("prev-track", self.on_prev_track)
add_action("repeat-press", self.on_repeat_press)
add_action("shuffle-press", self.on_shuffle_press)
# Navigation actions.
add_action("play-next", self.on_play_next, parameter_type="as")
add_action("add-to-queue", self.on_add_to_queue, parameter_type="as")
add_action("go-to-album", self.on_go_to_album, parameter_type="s")
add_action("go-to-artist", self.on_go_to_artist, parameter_type="s")
add_action("browse-to", self.browse_to, parameter_type="s")
add_action("go-to-playlist", self.on_go_to_playlist, parameter_type="s")
add_action("mute-toggle", self.on_mute_toggle)
add_action(
"update-play-queue-from-server",
lambda a, p: self.update_play_state_from_server(),
)
if tap_imported:
self.tap = osxmmkeys.Tap()
self.tap.on("play_pause", self.on_play_pause)
self.tap.on("next_track", self.on_next_track)
self.tap.on("prev_track", self.on_prev_track)
self.tap.start()
def do_activate(self):
# We only allow a single window and raise any existing ones
if self.window:
self.window.present()
return
# Windows are associated with the application when the last one is
# closed the application shuts down.
self.window = MainWindow(application=self, title="Sublime Music")
# Configure the CSS provider so that we can style elements on the
# window.
css_provider = Gtk.CssProvider()
css_provider.load_from_path(
os.path.join(os.path.dirname(__file__), "ui/app_styles.css")
)
context = Gtk.StyleContext()
screen = Gdk.Screen.get_default()
context.add_provider_for_screen(
screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER
)
self.window.stack.connect(
"notify::visible-child", self.on_stack_change,
)
self.window.connect("song-clicked", self.on_song_clicked)
self.window.connect("songs-removed", self.on_songs_removed)
self.window.connect("refresh-window", self.on_refresh_window)
self.window.connect("go-to", self.on_window_go_to)
self.window.connect("key-press-event", self.on_window_key_press)
self.window.player_controls.connect("song-scrub", self.on_song_scrub)
self.window.player_controls.connect("device-update", self.on_device_update)
self.window.player_controls.connect("volume-change", self.on_volume_change)
self.window.show_all()
self.window.present()
# Load the state for the server, if it exists.
self.app_config.load_state()
# If there is no current server, show the dialog to select a server.
if self.app_config.server is None:
self.show_configure_servers_dialog()
# If they didn't add one with the dialog, close the window.
if self.app_config.server is None:
self.window.close()
return
self.update_window()
# Configure the players
self.last_play_queue_update = 0
self.loading_state = False
self.should_scrobble_song = False
def time_observer(value: Optional[float]):
if (
self.loading_state
or not self.window
or not self.app_config.state.current_song
):
return
if value is None:
self.last_play_queue_update = 0
return
self.app_config.state.song_progress = value
GLib.idle_add(
self.window.player_controls.update_scrubber,
self.app_config.state.song_progress,
self.app_config.state.current_song.duration,
)
if self.last_play_queue_update + 15 <= value:
self.save_play_queue()
if value > 5 and self.should_scrobble_song:
CacheManager.scrobble(self.app_config.state.current_song.id)
self.should_scrobble_song = False
def on_track_end():
at_end = (
self.app_config.state.current_song_index
== len(self.app_config.state.play_queue) - 1
)
no_repeat = self.app_config.state.repeat_type == RepeatType.NO_REPEAT
if at_end and no_repeat:
self.app_config.state.playing = False
self.app_config.state.current_song_index = -1
GLib.idle_add(self.update_window)
return
GLib.idle_add(self.on_next_track)
@dbus_propagate(self)
def on_player_event(event: PlayerEvent):
if event.name == "play_state_change":
self.app_config.state.playing = event.value
elif event.name == "volume_change":
self.app_config.state.volume = event.value
GLib.idle_add(self.update_window)
self.mpv_player = MPVPlayer(
time_observer, on_track_end, on_player_event, self.app_config,
)
self.chromecast_player = ChromecastPlayer(
time_observer, on_track_end, on_player_event, self.app_config,
)
self.player = self.mpv_player
if self.app_config.state.current_device != "this device":
# TODO (#120)
pass
self.app_config.state.current_device = "this device"
# Need to do this after we set the current device.
self.player.volume = self.app_config.state.volume
# Prompt to load the play queue from the server.
if self.app_config.server.sync_enabled:
self.update_play_state_from_server(prompt_confirm=True)
# Send out to the bus that we exist.
if self.dbus_manager:
self.dbus_manager.property_diff()
# ########## DBUS MANAGMENT ########## #
def do_dbus_register(self, connection: Gio.DBusConnection, path: str) -> bool:
self.dbus_manager = DBusManager(
connection,
self.on_dbus_method_call,
self.on_dbus_set_property,
lambda: (self.app_config, self.player),
)
return True
def on_dbus_method_call(
self,
connection: Gio.DBusConnection,
sender: str,
path: str,
interface: str,
method: str,
params: GLib.Variant,
invocation: Gio.DBusMethodInvocation,
):
second_microsecond_conversion = 1000000
def seek_fn(offset: float):
if not self.app_config.state.current_song:
return
offset_seconds = offset / second_microsecond_conversion
new_seconds = self.app_config.state.song_progress + offset_seconds
# This should not ever happen. The current_song should always have
# a duration, but the Child object has `duration` optional because
# it could be a directory.
assert self.app_config.state.current_song.duration is not None
self.on_song_scrub(
None, new_seconds / self.app_config.state.current_song.duration * 100,
)
def set_pos_fn(track_id: str, position: float = 0):
if self.app_config.state.playing:
self.on_play_pause()
pos_seconds = position / second_microsecond_conversion
self.app_config.state.song_progress = pos_seconds
track_id, occurrence = track_id.split("/")[-2:]
# Find the (N-1)th time that the track id shows up in the list. (N
# is the -*** suffix on the track id.)
song_index = [
i
for i, x in enumerate(self.app_config.state.play_queue)
if x == track_id
][int(occurrence) or 0]
self.play_song(song_index)
def get_tracks_metadata(track_ids: List[str]) -> GLib.Variant:
if not self.dbus_manager:
return
if len(track_ids) == 0:
# We are lucky, just return an empty list.
return GLib.Variant("(aa{sv})", ([],))
# Have to calculate all of the metadatas so that we can deal with
# repeat song IDs.
metadatas: Iterable[Any] = [
self.dbus_manager.get_mpris_metadata(
i, self.app_config.state.play_queue,
)
for i in range(len(self.app_config.state.play_queue))
]
# Get rid of all of the tracks that were not requested.
metadatas = list(
filter(lambda m: m["mpris:trackid"] in track_ids, metadatas)
)
assert len(metadatas) == len(track_ids)
# Sort them so they get returned in the same order as they were
# requested.
metadatas = sorted(
metadatas, key=lambda m: track_ids.index(m["mpris:trackid"])
)
# Turn them into dictionaries that can actually be serialized into
# a GLib.Variant.
metadatas = map(
lambda m: {k: DBusManager.to_variant(v) for k, v in m.items()},
metadatas,
)
return GLib.Variant("(aa{sv})", (list(metadatas),))
def activate_playlist(playlist_id: str):
playlist_id = playlist_id.split("/")[-1]
playlist = AdapterManager.get_playlist_details(playlist_id).result()
# Calculate the song id to play.
song_idx = 0
if self.app_config.state.shuffle_on:
song_idx = random.randint(0, len(playlist.songs) - 1)
self.on_song_clicked(
None,
song_idx,
[s.id for s in playlist.songs],
{"active_playlist_id": playlist_id},
)
def get_playlists(
index: int, max_count: int, order: str, reverse_order: bool,
) -> GLib.Variant:
playlists_result = AdapterManager.get_playlists()
if not playlists_result.data_is_available:
# We don't want to wait for the response in this case, so just
# return an empty array.
return GLib.Variant("(a(oss))", ([],))
playlists = list(playlists_result.result())
sorters = {
"Alphabetical": lambda p: p.name,
"Created": lambda p: p.created,
"Modified": lambda p: p.changed,
}
playlists.sort(
key=sorters.get(order, lambda p: p), reverse=reverse_order,
)
def make_playlist_tuple(p: Playlist) -> GLib.Variant:
cover_art_filename = CacheManager.get_cover_art_filename(
p.cover_art, allow_download=False,
).result()
return (f"/playlist/{p.id}", p.name, cover_art_filename or "")
return GLib.Variant(
"(a(oss))",
(
[
make_playlist_tuple(p)
for p in playlists[index : (index + max_count)]
],
),
)
def play():
if not self.app_config.state.playing:
self.on_play_pause()
def pause():
if self.app_config.state.playing:
self.on_play_pause()
method_call_map: Dict[str, Dict[str, Any]] = {
"org.mpris.MediaPlayer2": {
"Raise": self.window and self.window.present,
"Quit": self.window and self.window.destroy,
},
"org.mpris.MediaPlayer2.Player": {
"Next": self.on_next_track,
"Previous": self.on_prev_track,
"Pause": pause,
"PlayPause": self.on_play_pause,
"Stop": pause,
"Play": play,
"Seek": seek_fn,
"SetPosition": set_pos_fn,
},
"org.mpris.MediaPlayer2.TrackList": {
"GoTo": set_pos_fn,
"GetTracksMetadata": get_tracks_metadata,
# 'RemoveTrack': remove_track,
},
"org.mpris.MediaPlayer2.Playlists": {
"ActivatePlaylist": activate_playlist,
"GetPlaylists": get_playlists,
},
}
method_fn = method_call_map.get(interface, {}).get(method)
if method_fn is None:
logging.warning(f"Unknown/unimplemented method: {interface}.{method}.")
invocation.return_value(method_fn(*params) if callable(method_fn) else None)
def on_dbus_set_property(
self,
connection: Gio.DBusConnection,
sender: str,
path: str,
interface: str,
property_name: str,
value: GLib.Variant,
):
def change_loop(new_loop_status: GLib.Variant):
self.app_config.state.repeat_type = RepeatType.from_mpris_loop_status(
new_loop_status.get_string()
)
self.update_window()
def set_shuffle(new_val: GLib.Variant):
if new_val.get_boolean() != self.app_config.state.shuffle_on:
self.on_shuffle_press(None, None)
def set_volume(new_val: GLib.Variant):
self.on_volume_change(None, new_val.get_double() * 100)
setter_map: Dict[str, Dict[str, Any]] = {
"org.mpris.MediaPlayer2.Player": {
"LoopStatus": change_loop,
"Rate": lambda _: None,
"Shuffle": set_shuffle,
"Volume": set_volume,
}
}
setter = setter_map.get(interface, {}).get(property_name)
if setter is None:
logging.warning("Set: Unknown property: {property_name}.")
return
if callable(setter):
setter(value)
# ########## ACTION HANDLERS ########## #
@dbus_propagate()
def on_refresh_window(
self, _, state_updates: Dict[str, Any], force: bool = False,
):
for k, v in state_updates.items():
setattr(self.app_config.state, k, v)
self.update_window(force=force)
def on_configure_servers(self, *args):
self.show_configure_servers_dialog()
def on_settings(self, *args):
"""Show the Settings dialog."""
dialog = SettingsDialog(self.window, self.app_config)
result = dialog.run()
if result == Gtk.ResponseType.OK:
self.app_config.port_number = int(dialog.data["port_number"].get_text())
self.app_config.always_stream = dialog.data["always_stream"].get_active()
self.app_config.download_on_stream = dialog.data[
"download_on_stream"
].get_active()
self.app_config.song_play_notification = dialog.data[
"song_play_notification"
].get_active()
self.app_config.serve_over_lan = dialog.data["serve_over_lan"].get_active()
self.app_config.prefetch_amount = dialog.data[
"prefetch_amount"
].get_value_as_int()
self.app_config.concurrent_download_limit = dialog.data[
"concurrent_download_limit"
].get_value_as_int()
self.app_config.replay_gain = ReplayGainType.from_string(
dialog.data["replay_gain"].get_active_id()
)
self.app_config.save()
self.reset_state()
dialog.destroy()
def on_window_go_to(self, win: Any, action: str, value: str):
{
"album": self.on_go_to_album,
"artist": self.on_go_to_artist,
"playlist": self.on_go_to_playlist,
}[action](None, GLib.Variant("s", value))
@dbus_propagate()
def on_play_pause(self, *args):
if self.app_config.state.current_song_index < 0:
return
if self.player.song_loaded:
self.player.toggle_play()
self.save_play_queue()
else:
# This is from a restart, start playing the file.
self.play_song(self.app_config.state.current_song_index)
self.app_config.state.playing = not self.app_config.state.playing
self.update_window()
def on_next_track(self, *args):
# Handle song repeating
if self.app_config.state.repeat_type == RepeatType.REPEAT_SONG:
song_index_to_play = self.app_config.state.current_song_index
# Wrap around the play queue if at the end.
elif (
self.app_config.state.current_song_index
== len(self.app_config.state.play_queue) - 1
):
# This may happen due to D-Bus.
if self.app_config.state.repeat_type == RepeatType.NO_REPEAT:
return
song_index_to_play = 0
else:
song_index_to_play = self.app_config.state.current_song_index + 1
self.play_song(song_index_to_play, reset=True)
def on_prev_track(self, *args):
# Go back to the beginning of the song if we are past 5 seconds.
# Otherwise, go to the previous song.
no_repeat = self.app_config.state.repeat_type == RepeatType.NO_REPEAT
if self.app_config.state.repeat_type == RepeatType.REPEAT_SONG:
song_index_to_play = self.app_config.state.current_song_index
elif self.app_config.state.song_progress < 5:
if self.app_config.state.current_song_index == 0 and no_repeat:
song_index_to_play = 0
else:
song_index_to_play = (
self.app_config.state.current_song_index - 1
) % len(self.app_config.state.play_queue)
else:
# Go back to the beginning of the song.
song_index_to_play = self.app_config.state.current_song_index
self.play_song(song_index_to_play, reset=True)
@dbus_propagate()
def on_repeat_press(self, *args):
# Cycle through the repeat types.
new_repeat_type = RepeatType((self.app_config.state.repeat_type.value + 1) % 3)
self.app_config.state.repeat_type = new_repeat_type
self.update_window()
@dbus_propagate()
def on_shuffle_press(self, *args):
if self.app_config.state.shuffle_on:
# Revert to the old play queue.
self.app_config.state.current_song_index = self.app_config.state.old_play_queue.index(
self.app_config.state.current_song.id
)
self.app_config.state.play_queue = (
self.app_config.state.old_play_queue.copy()
)
else:
self.app_config.state.old_play_queue = (
self.app_config.state.play_queue.copy()
)
# Remove the current song, then shuffle and put the song back.
song_id = self.app_config.state.current_song.id
del self.app_config.state.play_queue[
self.app_config.state.current_song_index
]
random.shuffle(self.app_config.state.play_queue)
self.app_config.state.play_queue = [
song_id
] + self.app_config.state.play_queue
self.app_config.state.current_song_index = 0
self.app_config.state.shuffle_on = not self.app_config.state.shuffle_on
self.update_window()
@dbus_propagate()
def on_play_next(self, action: Any, song_ids: List[str]):
if self.app_config.state.current_song is None:
insert_at = 0
else:
insert_at = self.app_config.state.current_song_index + 1
self.app_config.state.play_queue = (
self.app_config.state.play_queue[:insert_at]
+ list(song_ids)
+ self.app_config.state.play_queue[insert_at:]
)
self.app_config.state.old_play_queue.extend(song_ids)
self.update_window()
@dbus_propagate()
def on_add_to_queue(self, action: Any, song_ids: GLib.Variant):
self.app_config.state.play_queue.extend(song_ids)
self.app_config.state.old_play_queue.extend(song_ids)
self.update_window()
def on_go_to_album(self, action: Any, album_id: GLib.Variant):
# Switch to the By Year view (or genre, if year is not available) to
# guarantee that the album is there.
album = CacheManager.get_album(album_id.get_string()).result()
if isinstance(album, Directory):
if len(album.child) > 0:
album = album.child[0]
if year := album.get("year"):
self.app_config.state.current_album_sort = "byYear"
self.app_config.state.current_album_from_year = year
self.app_config.state.current_album_to_year = year
elif genre := album.get("genre"):
self.app_config.state.current_album_sort = "byGenre"
self.app_config.state.current_album_genre = genre
else:
dialog = Gtk.MessageDialog(
transient_for=self.window,
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK,
text="Could not go to album",
)
dialog.format_secondary_markup(
"Could not go to the album because it does not have a year or " "genre."
)
dialog.run()
dialog.destroy()
return
self.app_config.state.current_tab = "albums"
self.app_config.state.selected_album_id = album_id.get_string()
self.update_window(force=True)
def on_go_to_artist(self, action: Any, artist_id: GLib.Variant):
self.app_config.state.current_tab = "artists"
self.app_config.state.selected_artist_id = artist_id.get_string()
self.update_window()
def browse_to(self, action: Any, item_id: GLib.Variant):
self.app_config.state.current_tab = "browse"
self.app_config.state.selected_browse_element_id = item_id.get_string()
self.update_window()
def on_go_to_playlist(self, action: Any, playlist_id: GLib.Variant):
self.app_config.state.current_tab = "playlists"
self.app_config.state.selected_playlist_id = playlist_id.get_string()
self.update_window()
def on_server_list_changed(self, action: Any, servers: GLib.Variant):
# TODO do the save to the keyring here
# keyring.set_password(
# 'com.sumnerevans.SublimeMusic',
# f'{self.username}@{self.server_address}',
# password,
# )
# def get_password(self) -> str:
# try:
# return keyring.get_password(
# 'com.sumnerevans.SublimeMusic',
# f'{self.username}@{self.server_address}',
# )
# except Exception:
# return self.password
self.app_config.servers = servers
self.app_config.save()
def on_connected_server_changed(
self, action: Any, current_server_index: int,
):
if self.app_config.server:
self.app_config.save()
self.app_config.current_server_index = current_server_index
self.app_config.save()
self.reset_state()
def reset_state(self):
if self.app_config.state.playing:
self.on_play_pause()
self.loading_state = True
self.app_config.state.load()
self.player.reset()
self.loading_state = False
# Update the window according to the new server configuration.
self.update_window()
def on_stack_change(self, stack: Gtk.Stack, _):
self.app_config.state.current_tab = stack.get_visible_child_name()
self.update_window()
def on_song_clicked(
self,
win: Any,
song_index: int,
song_queue: List[str],
metadata: Dict[str, Any],
):
# Reset the play queue so that we don't ever revert back to the
# previous one.
old_play_queue = song_queue.copy()
if (force_shuffle := metadata.get("force_shuffle_state")) is not None:
self.app_config.state.shuffle_on = force_shuffle
self.app_config.state.active_playlist_id = metadata.get("active_playlist_id")
# If shuffle is enabled, then shuffle the playlist.
if self.app_config.state.shuffle_on and not metadata.get("no_reshuffle"):
song_id = song_queue[song_index]
del song_queue[song_index]
random.shuffle(song_queue)
song_queue = [song_id] + song_queue
song_index = 0
self.play_song(
song_index,
reset=True,
old_play_queue=old_play_queue,
play_queue=song_queue,
)
def on_songs_removed(self, win: Any, song_indexes_to_remove: List[int]):
self.app_config.state.play_queue = [
song_id
for i, song_id in enumerate(self.app_config.state.play_queue)
if i not in song_indexes_to_remove
]
# Determine how many songs before the currently playing one were also
# deleted.
before_current = [
i
for i in song_indexes_to_remove
if i < self.app_config.state.current_song_index
]
if self.app_config.state.current_song_index in song_indexes_to_remove:
if len(self.app_config.state.play_queue) == 0:
self.on_play_pause()
self.app_config.state.current_song_index = -1
self.update_window()
return
self.app_config.state.current_song_index -= len(before_current)
self.play_song(
self.app_config.state.current_song_index, reset=True,
)
else:
self.app_config.state.current_song_index -= len(before_current)
self.update_window()
self.save_play_queue()
@dbus_propagate()
def on_song_scrub(self, win: Any, scrub_value: float):
if not self.app_config.state.current_song or not self.window:
return
# This should not ever happen. The current_song should always have
# a duration, but the Child object has `duration` optional because
# it could be a directory.
assert self.app_config.state.current_song.duration is not None
new_time = self.app_config.state.current_song.duration * (scrub_value / 100)
self.app_config.state.song_progress = new_time
self.window.player_controls.update_scrubber(
self.app_config.state.song_progress,
self.app_config.state.current_song.duration,
)
# If already playing, then make the player itself seek.
if self.player.song_loaded:
self.player.seek(new_time)
self.save_play_queue()
def on_device_update(self, win: Any, device_uuid: str):
if device_uuid == self.app_config.state.current_device:
return
self.app_config.state.current_device = device_uuid
was_playing = self.app_config.state.playing
self.player.pause()
self.player._song_loaded = False
self.app_config.state.playing = False
if self.dbus_manager:
self.dbus_manager.property_diff()
self.update_window()
if device_uuid == "this device":
self.player = self.mpv_player
else:
self.chromecast_player.set_playing_chromecast(device_uuid)
self.player = self.chromecast_player
if was_playing:
self.on_play_pause()
if self.dbus_manager:
self.dbus_manager.property_diff()
@dbus_propagate()
def on_mute_toggle(self, *args):
self.app_config.state.is_muted = not self.app_config.state.is_muted
self.player.is_muted = self.app_config.state.is_muted
self.update_window()
@dbus_propagate()
def on_volume_change(self, _, value: float):
self.app_config.state.volume = value
self.player.volume = self.app_config.state.volume
self.update_window()
def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey,) -> bool:
# Need to use bitwise & here to see if CTRL is pressed.
if event.keyval == 102 and event.state & Gdk.ModifierType.CONTROL_MASK:
# Ctrl + F
window.search_entry.grab_focus()
return False
if window.search_entry.has_focus():
return False
keymap = {
32: self.on_play_pause,
65360: self.on_prev_track,
65367: self.on_next_track,
}
action = keymap.get(event.keyval)
if action:
action()
return True
return False
def on_app_shutdown(self, app: "SublimeMusicApp"):
if glib_notify_exists:
Notify.uninit()
if tap_imported and self.tap:
self.tap.stop()
if self.app_config.server is None:
return
self.player.pause()
self.chromecast_player.shutdown()
self.mpv_player.shutdown()
self.app_config.save()
self.save_play_queue()
if self.dbus_manager:
self.dbus_manager.shutdown()
CacheManager.shutdown()
AdapterManager.shutdown()
# ########## HELPER METHODS ########## #
def show_configure_servers_dialog(self):
"""Show the Connect to Server dialog."""
dialog = ConfigureServersDialog(self.window, self.app_config)
dialog.connect("server-list-changed", self.on_server_list_changed)
dialog.connect("connected-server-changed", self.on_connected_server_changed)
dialog.run()
dialog.destroy()
def update_window(self, force: bool = False):
if not self.window:
return
GLib.idle_add(lambda: self.window.update(self.app_config, force=force))
def update_play_state_from_server(self, prompt_confirm: bool = False):
# TODO (#129): need to make the up next list loading for the duration
# here if prompt_confirm is False.
was_playing = self.app_config.state.playing
self.player.pause()
self.app_config.state.playing = False
self.update_window()
def do_update(f: Future):
play_queue = f.result()
new_play_queue = [s.id for s in play_queue.entry]
new_current_song_id = str(play_queue.current)
new_song_progress = play_queue.position / 1000
if prompt_confirm:
# If there's not a significant enough difference, don't prompt.
progress_diff = 15
if self.app_config.state.song_progress:
progress_diff = abs(
self.app_config.state.song_progress - new_song_progress
)
if (
self.app_config.state.play_queue == new_play_queue
and self.app_config.state.current_song
):
song_id = self.app_config.state.current_song.id
if song_id == new_current_song_id and progress_diff < 15:
return
dialog = Gtk.MessageDialog(
transient_for=self.window,
message_type=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.YES_NO,
text="Resume Playback?",
)
dialog.format_secondary_markup(
"Do you want to resume the play queue saved by "
+ str(play_queue.changedBy)
+ " at "
+ play_queue.changed.astimezone(tz=None).strftime(
"%H:%M on %Y-%m-%d"
)
+ "?"
)
result = dialog.run()
dialog.destroy()
if result != Gtk.ResponseType.YES:
return
self.app_config.state.play_queue = new_play_queue
self.app_config.state.song_progress = play_queue.position / 1000
self.app_config.state.current_song_index = self.app_config.state.play_queue.index(
new_current_song_id
)
self.player.reset()
self.update_window()
if was_playing:
self.on_play_pause()
play_queue_future = CacheManager.get_play_queue()
play_queue_future.add_done_callback(lambda f: GLib.idle_add(do_update, f))
song_playing_order_token = 0
def play_song(
self,
song_index: int,
reset: bool = False,
old_play_queue: List[str] = None,
play_queue: List[str] = None,
):
# Do this the old fashioned way so that we can have access to ``reset``
# in the callback.
@dbus_propagate(self)
def do_play_song(song: Child):
uri, stream = CacheManager.get_song_filename_or_stream(
song, force_stream=self.app_config.always_stream,
)
# Prevent it from doing the thing where it continually loads
# songs when it has to download.
if reset:
self.player.reset()
self.app_config.state.song_progress = 0
self.should_scrobble_song = True
# Show a song play notification.
if self.app_config.song_play_notification:
try:
if glib_notify_exists:
notification_lines = []
if song.album:
notification_lines.append(f"<i>{song.album}</i>")
if song.artist:
notification_lines.append(song.artist)
song_notification = Notify.Notification.new(
song.title, "\n".join(notification_lines),
)
song_notification.add_action(
"clicked",
"Open Sublime Music",
lambda *a: self.window.present() if self.window else None,
)
song_notification.show()
def on_cover_art_download_complete(
cover_art_filename: str, order_token: int,
):
if order_token != self.song_playing_order_token:
return
# Add the image to the notification, and re-show
# the notification.
song_notification.set_image_from_pixbuf(
GdkPixbuf.Pixbuf.new_from_file_at_scale(
cover_art_filename, 70, 70, True
)
)
song_notification.show()
def get_cover_art_filename(order_token: int) -> Tuple[str, int]:
return (
CacheManager.get_cover_art_filename(
song.coverArt
).result(),
order_token,
)
self.song_playing_order_token += 1
cover_art_future = CacheManager.create_future(
get_cover_art_filename, self.song_playing_order_token,
)
cover_art_future.add_done_callback(
lambda f: on_cover_art_download_complete(*f.result())
)
if sys.platform == "darwin":
notification_lines = []
if song.album:
notification_lines.append(song.album)
if song.artist:
notification_lines.append(song.artist)
notification_text = "\n".join(notification_lines)
osascript_command = [
"display",
"notification",
f'"{notification_text}"',
"with",
"title",
f'"{song.title}"',
]
os.system(f"osascript -e '{' '.join(osascript_command)}'")
except Exception:
logging.warning(
"Unable to display notification. Is a notification "
"daemon running?"
)
def on_song_download_complete(song_id: int):
if (
self.app_config.state.current_song
and self.app_config.state.current_song.id != song.id
):
return
if not self.app_config.state.playing:
return
# Switch to the local media if the player can hotswap (MPV can,
# Chromecast cannot hotswap without lag).
if self.player.can_hotswap_source:
self.player.play_media(
CacheManager.get_song_filename_or_stream(song)[0],
self.app_config.state.song_progress,
song,
)
GLib.idle_add(self.update_window)
# If streaming, also download the song, unless configured not to,
# or configured to always stream.
if (
stream
and self.app_config.download_on_stream
and not self.app_config.always_stream
):
CacheManager.batch_download_songs(
[song.id],
before_download=lambda: self.update_window(),
on_song_download_complete=on_song_download_complete,
)
self.player.play_media(
uri, 0 if reset else self.app_config.state.song_progress, song,
)
self.app_config.state.playing = True
self.update_window()
# Prefetch songs
if self.app_config.state.repeat_type != RepeatType.REPEAT_SONG:
song_idx = self.app_config.state.play_queue.index(song.id)
repeat_type = self.app_config.state.repeat_type
is_repeat_queue = RepeatType.REPEAT_QUEUE == repeat_type
prefetch_idxs = []
for i in range(self.app_config.prefetch_amount):
prefetch_idx: int = song_idx + 1 + i
play_queue_len: int = len(self.app_config.state.play_queue)
if is_repeat_queue or prefetch_idx < play_queue_len:
prefetch_idxs.append(
prefetch_idx % play_queue_len
) # noqa: S001
CacheManager.batch_download_songs(
[self.app_config.state.play_queue[i] for i in prefetch_idxs],
before_download=lambda: GLib.idle_add(self.update_window),
on_song_download_complete=lambda _: GLib.idle_add(
self.update_window
),
)
if old_play_queue:
self.app_config.state.old_play_queue = old_play_queue
if play_queue:
self.app_config.state.play_queue = play_queue
self.app_config.state.current_song_index = song_index
if play_queue:
self.save_play_queue()
song_details_future = CacheManager.get_song_details(
self.app_config.state.play_queue[self.app_config.state.current_song_index]
)
song_details_future.add_done_callback(
lambda f: GLib.idle_add(do_play_song, f.result()),
)
def save_play_queue(self):
if len(self.app_config.state.play_queue) == 0:
return
position = self.app_config.state.song_progress
self.last_play_queue_update = position or 0
if self.app_config.server.sync_enabled and self.app_config.state.current_song:
CacheManager.save_play_queue(
play_queue=self.app_config.state.play_queue,
current=self.app_config.state.current_song.id,
position=math.floor(position * 1000) if position else None,
)