1135 lines
42 KiB
Python
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,
|
|
)
|