diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py index 555fcff..d575a30 100644 --- a/sublime/adapters/subsonic/adapter.py +++ b/sublime/adapters/subsonic/adapter.py @@ -651,6 +651,7 @@ class SubsonicAdapter(Adapter): self._get( self._make_url("savePlayQueue"), id=song_ids, + timeout=2, current=song_ids[current_song_index] if current_song_index is not None else None, diff --git a/sublime/app.py b/sublime/app.py index 12dd86a..5b88081 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -265,7 +265,39 @@ class SublimeMusicApp(Gtk.Application): ) def player_device_change_callback(event: PlayerDeviceEvent): - pass + assert self.player_manager + state_device = self.app_config.state.current_device + + if event.delta == PlayerDeviceEvent.Delta.ADD: + # If the device added is the one that's supposed to be active, activate + # it and set the volume. + if event.id == state_device: + self.player_manager.set_current_device_id( + self.app_config.state.current_device + ) + self.player_manager.set_volume(self.app_config.state.volume) + self.app_config.state.connecting_to_device = False + # TODO actually add it + print("ADDED", event) + + elif event.delta == PlayerDeviceEvent.Delta.REMOVE: + print("REMOVED", event) + + self.update_window() + + self.app_config.state.connecting_to_device = True + + def check_if_connected(): + if not self.app_config.state.connecting_to_device: + return + print( + f"Couldn't find device {self.app_config.state.current_device} in time." + ) + self.app_config.state.current_device = "this device" + self.player_manager.set_current_device_id( + self.app_config.state.current_device + ) + self.update_window() self.player_manager = PlayerManager( on_timepos_change, @@ -274,15 +306,8 @@ class SublimeMusicApp(Gtk.Application): player_device_change_callback, self.app_config.player_config, ) - - if self.app_config.state.current_device != "this device": - # TODO (#120) attempt to connect to the previously connected device - 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 + self.player_manager.init_players() + GLib.timeout_add(10000, check_if_connected) # Update after Adapter Initial Sync inital_sync_result = AdapterManager.initial_sync() @@ -311,7 +336,7 @@ class SublimeMusicApp(Gtk.Application): connection, self.on_dbus_method_call, self.on_dbus_set_property, - lambda: (self.app_config, self.player), + lambda: (self.app_config, self.player_manager), ) return True @@ -604,8 +629,8 @@ class SublimeMusicApp(Gtk.Application): self.app_config.state.playing = not self.app_config.state.playing - if self.player.song_loaded: - self.player.toggle_play() + if self.player_manager.song_loaded: + self.player_manager.toggle_play() self.save_play_queue() else: # This is from a restart, start playing the file. @@ -743,7 +768,7 @@ class SublimeMusicApp(Gtk.Application): if self.app_config.state.playing: self.on_play_pause() self.loading_state = True - self.player.reset() + self.player_manager.reset() AdapterManager.reset(self.app_config, self.on_song_download_progress) self.loading_state = False @@ -838,20 +863,21 @@ class SublimeMusicApp(Gtk.Application): ) # If already playing, then make the player itself seek. - if self.player and self.player.song_loaded: - self.player.seek(new_time) + if self.player_manager and self.player_manager.song_loaded: + self.player_manager.seek(new_time) self.save_play_queue() def on_device_update(self, win: Any, device_uuid: str): - assert self.player + # TODO + assert self.player_manager 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.player_manager.pause() + self.player_manager._song_loaded = False self.app_config.state.playing = False if self.dbus_manager: @@ -873,14 +899,14 @@ class SublimeMusicApp(Gtk.Application): @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.player_manager.set_muted(self.app_config.state.is_muted) self.update_window() @dbus_propagate() def on_volume_change(self, _, value: float): - assert self.player + assert self.player_manager self.app_config.state.volume = value - self.player.volume = self.app_config.state.volume + self.player_manager.set_volume(self.app_config.state.volume) self.update_window() def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey) -> bool: @@ -926,13 +952,11 @@ class SublimeMusicApp(Gtk.Application): if self.app_config.provider is None: return - if self.player: - self.player.pause() - self.chromecast_player.shutdown() - self.mpv_player.shutdown() + if self.player_manager: + self.player_manager.pause() + self.player_manager.shutdown() self.app_config.save() - self.save_play_queue() if self.dbus_manager: self.dbus_manager.shutdown() AdapterManager.shutdown() @@ -973,7 +997,7 @@ class SublimeMusicApp(Gtk.Application): # prompt_confirm is False. if not prompt_confirm and self.app_config.state.playing: assert self.player - self.player.pause() + self.player_manager.pause() self.app_config.state.playing = False self.update_window() @@ -1025,7 +1049,7 @@ class SublimeMusicApp(Gtk.Application): self.app_config.state.current_song_index = ( play_queue.current_index or 0 ) - self.player.reset() + self.player_manager.reset() self.app_config.state.current_notification = None self.update_window() @@ -1053,7 +1077,7 @@ class SublimeMusicApp(Gtk.Application): playable_song_search_direction: int = 1, ): def do_reset(): - self.player.reset() + self.player_manager.reset() self.app_config.state.song_progress = timedelta(0) self.should_scrobble_song = True @@ -1061,7 +1085,7 @@ class SublimeMusicApp(Gtk.Application): # in the callback. @dbus_propagate(self) def do_play_song(order_token: int, song: Song): - assert self.player + assert self.player_manager if order_token != self.song_playing_order_token: return @@ -1076,7 +1100,7 @@ class SublimeMusicApp(Gtk.Application): if order_token != self.song_playing_order_token: return - self.player.play_media( + self.player_manager.play_media( uri, timedelta(0) if reset else self.app_config.state.song_progress, song, @@ -1163,9 +1187,10 @@ class SublimeMusicApp(Gtk.Application): # Switch to the local media if the player can hotswap without lag. # For example, MPV can is barely noticable whereas there's quite a # delay with Chromecast. - assert self.player - if self.player.can_hotswap_source: - self.player.play_media( + assert self.player_manager + if self.player_manager.can_start_playing_with_no_latency: + print(self.player_manager) + self.player_manager.play_media( AdapterManager.get_song_filename_or_stream(song), self.app_config.state.song_progress, song, diff --git a/sublime/dbus/manager.py b/sublime/dbus/manager.py index 94cc3b5..64b4873 100644 --- a/sublime/dbus/manager.py +++ b/sublime/dbus/manager.py @@ -190,7 +190,7 @@ class DBusManager: def property_dict(self) -> Dict[str, Any]: config, player_manager = self.get_config_and_player_manager() - if config is None or player is None: + if config is None or player_manager is None: return {} state = config.state @@ -227,7 +227,7 @@ class DBusManager: (False, True): "Stopped", (True, False): "Paused", (True, True): "Playing", - }[player is not None and player.song_loaded, state.playing], + }[player_manager.song_loaded, state.playing], "LoopStatus": state.repeat_type.as_mpris_loop_status(), "Rate": 1.0, "Shuffle": state.shuffle_on, diff --git a/sublime/players/base.py b/sublime/players/base.py index e5d5dca..49714fb 100644 --- a/sublime/players/base.py +++ b/sublime/players/base.py @@ -66,6 +66,18 @@ class PlayerEvent: device_id: Optional[str] = None +@dataclass +class PlayerDeviceEvent: + class Delta(Enum): + ADD = 0 + REMOVE = 1 + + delta: Delta + player_type: Type + id: str + name: Optional[str] = None + + class Player(abc.ABC): @property @abc.abstractmethod @@ -107,6 +119,7 @@ class Player(abc.ABC): on_timepos_change: Callable[[Optional[float]], None], on_track_end: Callable[[], None], on_player_event: Callable[[PlayerEvent], None], + player_device_change_callback: Callable[[PlayerDeviceEvent], None], config: Dict[str, Union[str, int, bool]], ): """ @@ -126,12 +139,6 @@ class Player(abc.ABC): Do any cleanup of the player. """ - @abc.abstractmethod - def get_available_player_devices(self) -> Iterator[Tuple[str, str]]: - """ - :returns: an iterator of tuples containing the device ID and device name. - """ - @property @abc.abstractmethod def playing(self) -> bool: diff --git a/sublime/players/chromecast.py b/sublime/players/chromecast.py index 347b52d..b7062a0 100644 --- a/sublime/players/chromecast.py +++ b/sublime/players/chromecast.py @@ -23,7 +23,7 @@ from urllib.parse import urlparse from sublime.adapters import AdapterManager from sublime.adapters.api_objects import Song -from .base import Player, PlayerEvent +from .base import Player, PlayerDeviceEvent, PlayerEvent try: import pychromecast @@ -68,6 +68,7 @@ class ChromecastPlayer(Player): on_timepos_change: Callable[[Optional[float]], None], on_track_end: Callable[[], None], on_player_event: Callable[[PlayerEvent], None], + player_device_change_callback: Callable[[PlayerDeviceEvent], None], config: Dict[str, Union[str, int, bool]], ): self.server_process = None @@ -78,15 +79,33 @@ class ChromecastPlayer(Player): args=("0.0.0.0", self.config.get(LAN_PORT_KEY)), ) + self._stop_retrieve_chromecasts = None if chromecast_imported: - self._chromecasts: List[Any] = [] + self._chromecasts: Dict[str, pychromecast.Chromecast] = {} self._current_chromecast = pychromecast.Chromecast + def discovered_callback(chromecast: pychromecast.Chromecast): + self._chromecasts[chromecast.device.uuid] = chromecast + player_device_change_callback( + PlayerDeviceEvent( + PlayerDeviceEvent.Delta.ADD, + type(self), + str(chromecast.device.uuid), + chromecast.device.friendly_name, + ) + ) + + self._stop_retrieve_chromecasts = pychromecast.get_chromecasts( + blocking=False, callback=discovered_callback + ) + def shutdown(self): if self._current_chromecast: self._current_chromecast.disconnect() if self.server_process: self.server_process.terminate() + if self._stop_retrieve_chromecasts: + self._stop_retrieve_chromecasts() _serving_song_id = multiprocessing.Array("c", 1024) # huge buffer, just in case _serving_token = multiprocessing.Array("c", 12) @@ -121,14 +140,6 @@ class ChromecastPlayer(Player): bottle.run(app, host=host, port=port) - def get_available_player_devices(self) -> Iterator[Tuple[str, str]]: - if not chromecast_imported: - return - - self._chromecasts = pychromecast.get_chromecasts() - for chromecast in self._chromecasts: - yield (str(chromecast.device.uuid), chromecast.device.friendly_name) - @property def playing(self) -> bool: if ( diff --git a/sublime/players/manager.py b/sublime/players/manager.py index c3cc625..24178b8 100644 --- a/sublime/players/manager.py +++ b/sublime/players/manager.py @@ -1,6 +1,7 @@ import logging import multiprocessing from dataclasses import dataclass +from datetime import timedelta from enum import Enum from functools import partial from time import sleep @@ -17,27 +18,18 @@ from typing import ( Union, ) +from sublime.adapters.api_objects import Song -from .base import PlayerEvent +from .base import PlayerDeviceEvent, PlayerEvent from .chromecast import ChromecastPlayer # noqa: F401 from .mpv import MPVPlayer # noqa: F401 -@dataclass -class PlayerDeviceEvent: - class Delta(Enum): - ADD = 0 - REMOVE = 1 - - delta: Delta - player_type: Type - id: str - name: Optional[str] = None - - class PlayerManager: # Available Players. Order matters for UI display. available_player_types: List[Type] = [MPVPlayer, ChromecastPlayer] + # TODO + # player_device_retrieval_process: Optional[multiprocessing.Process] = None @staticmethod def get_configuration_options() -> Dict[ @@ -64,62 +56,105 @@ class PlayerManager: self.on_timepos_change = on_timepos_change self.on_track_end = on_track_end self.on_player_event = on_player_event + self.config = config + self.players: Dict[Type, Any] = {} + self.device_id_type_map: Dict[str, Type] = {} + self._current_device_id = None - self.players = [ - player_type( - on_timepos_change, - on_track_end, - on_player_event, - config.get(player_type.name), + def callback_wrapper(pde: PlayerDeviceEvent): + self.device_id_type_map[pde.id] = pde.player_type + player_device_change_callback(pde) + + self.player_device_change_callback = callback_wrapper + + # We have to have both init and and init_players so that by the time that any of the + # players start calling the callback, the player manager exists on the app. + def init_players(self): + self.players = { + player_type: player_type( + self.on_timepos_change, + self.on_track_end, + self.on_player_event, + self.player_device_change_callback, + self.config.get(player_type.name), ) for player_type in PlayerManager.available_player_types - ] + } - self.device_id_type_map: Dict[str, Type] = {} - self.player_device_change_callback = player_device_change_callback - self.player_device_retrieval_process = multiprocessing.Process( - target=self._retrieve_available_player_devices - ) + has_done_one_retrieval = multiprocessing.Value("b", False) def shutdown(self): - print("SHUTDOWN PLAYER MANAGER") - self.player_device_retrieval_process.terminate() + pass - def _retrieve_available_player_devices(self): - seen_ids = set() - while True: - new_ids = set() - for t in PlayerManager.available_player_types: - if not t.enabled: - continue - for device_id, device_name in t.get_available_player_devices(): - self.player_device_change_callback( - PlayerDeviceEvent( - PlayerDeviceEvent.Delta.ADD, t, device_id, device_name, - ) - ) - new_ids.add((t, device_id)) - self.device_id_type_map[device_id] = t + def _get_current_player_type(self) -> Any: + if device_id := self._current_device_id: + return self.device_id_type_map.get(device_id) - for t, device_id in seen_ids.difference(new_ids): - self.player_device_change_callback( - PlayerDeviceEvent(PlayerDeviceEvent.Delta.REMOVE, t, device_id) - ) - del self.device_id_type_map[device_id] + def _get_current_player(self) -> Any: + if current_player_type := self._get_current_player_type(): + return self.players.get(current_player_type) - seen_ids = new_ids - sleep(15) - - def can_start_playing_with_no_latency(self, device_id: str) -> bool: - return self.device_id_type_map[device_id].can_start_playing_with_no_latency - - _current_device_id = None + @property + def can_start_playing_with_no_latency(self) -> bool: + if self._current_device_id: + return self._get_current_player_type().can_start_playing_with_no_latency + else: + return False @property def current_device_id(self) -> Optional[str]: return self._current_device_id - @current_device_id.setter - def current_device_id(self, value: str): - print("SET CURRENT DEVICE") - self._current_device_id = value + def set_current_device_id(self, device_id: str): + logging.info(f"Setting current device id to '{device_id}'") + self._current_device_id = device_id + + def reset(self): + if current_player := self._get_current_player(): + current_player.reset() + + @property + def song_loaded(self) -> bool: + if current_player := self._get_current_player(): + return current_player.song_loaded + return False + + @property + def playing(self) -> bool: + if current_player := self._get_current_player(): + return current_player.playing + return False + + def get_volume(self) -> float: + if current_player := self._get_current_player(): + return current_player.get_volume() + return 100 + + def set_volume(self, volume: float): + if current_player := self._get_current_player(): + current_player.set_volume(volume) + + def get_is_muted(self) -> bool: + if current_player := self._get_current_player(): + return current_player.get_is_muted() + return False + + def set_muted(self, muted: bool): + if current_player := self._get_current_player(): + current_player.set_muted(muted) + + def play_media(self, uri: str, progress: timedelta, song: Song): + if current_player := self._get_current_player(): + current_player.play_media(uri, progress, song) + + def pause(self): + if current_player := self._get_current_player(): + current_player.pause() + + def toggle_play(self): + if current_player := self._get_current_player(): + current_player.toggle_play() + + def seek(self, position: timedelta): + if current_player := self._get_current_player(): + current_player.seek(position) diff --git a/sublime/players/mpv.py b/sublime/players/mpv.py index 055222d..19acbf5 100644 --- a/sublime/players/mpv.py +++ b/sublime/players/mpv.py @@ -17,7 +17,7 @@ import mpv from sublime.adapters.api_objects import Song -from .base import Player, PlayerEvent +from .base import Player, PlayerDeviceEvent, PlayerEvent REPLAY_GAIN_KEY = "Replay Gain" @@ -44,6 +44,7 @@ class MPVPlayer(Player): on_timepos_change: Callable[[Optional[float]], None], on_track_end: Callable[[], None], on_player_event: Callable[[PlayerEvent], None], + player_device_change_callback: Callable[[PlayerDeviceEvent], None], config: Dict[str, Union[str, int, bool]], ): self.mpv = mpv.MPV() @@ -75,6 +76,13 @@ class MPVPlayer(Player): ) ) + # Indicate to the UI that we exist. + player_device_change_callback( + PlayerDeviceEvent( + PlayerDeviceEvent.Delta.ADD, type(self), "this device", "This Device" + ) + ) + def shutdown(self): pass @@ -83,9 +91,6 @@ class MPVPlayer(Player): with self._progress_value_lock: self._progress_value_count = 0 - def get_available_player_devices(self) -> Iterator[Tuple[str, str]]: - yield ("this device", "This Device") - @property def playing(self) -> bool: return not self.mpv.pause diff --git a/sublime/ui/main.py b/sublime/ui/main.py index aa729b6..2d4d356 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -196,13 +196,14 @@ class MainWindow(Gtk.ApplicationWindow): # Main Settings self.notification_switch.set_active(app_config.song_play_notification) - # MPV Settings - self.replay_gain_options.set_active_id(app_config.replay_gain.as_string()) + # TODO + # # MPV Settings + # self.replay_gain_options.set_active_id(app_config.replay_gain.as_string()) - # Chromecast Settings - self.serve_over_lan_switch.set_active(app_config.serve_over_lan) - self.port_number_entry.set_value(app_config.port_number) - self.port_number_entry.set_sensitive(app_config.serve_over_lan) + # # Chromecast Settings + # self.serve_over_lan_switch.set_active(app_config.serve_over_lan) + # self.port_number_entry.set_value(app_config.port_number) + # self.port_number_entry.set_sensitive(app_config.serve_over_lan) # Download Settings allow_song_downloads = app_config.allow_song_downloads diff --git a/sublime/ui/state.py b/sublime/ui/state.py index 508c367..f612565 100644 --- a/sublime/ui/state.py +++ b/sublime/ui/state.py @@ -63,6 +63,7 @@ class UIState: song_progress: timedelta = timedelta() song_stream_cache_progress: Optional[timedelta] = timedelta() current_device: str = "this device" + connecting_to_device: bool = False # UI state current_tab: str = "albums" diff --git a/tests/player_tests/chromecast_tests.py b/tests/player_tests/chromecast_tests.py index f792f6e..522b2de 100644 --- a/tests/player_tests/chromecast_tests.py +++ b/tests/player_tests/chromecast_tests.py @@ -7,6 +7,7 @@ def test_init(): empty_fn, empty_fn, empty_fn, + empty_fn, { "Serve Local Files to Chromecasts on the LAN": True, "LAN Server Port Number": 6969, diff --git a/tests/player_tests/mpv_tests.py b/tests/player_tests/mpv_tests.py index 7a47bf2..3956eca 100644 --- a/tests/player_tests/mpv_tests.py +++ b/tests/player_tests/mpv_tests.py @@ -7,7 +7,7 @@ from sublime.players.mpv import MPVPlayer def test_init(): empty_fn = lambda *a, **k: None - MPVPlayer(empty_fn, empty_fn, empty_fn, {"Replay Gain": "Disabled"}) + MPVPlayer(empty_fn, empty_fn, empty_fn, empty_fn, {"Replay Gain": "Disabled"}) def is_close(expected: float, value: float, delta: float = 0.5) -> bool: @@ -16,7 +16,9 @@ def is_close(expected: float, value: float, delta: float = 0.5) -> bool: def test_play(): empty_fn = lambda *a, **k: None - mpv_player = MPVPlayer(empty_fn, empty_fn, empty_fn, {"Replay Gain": "Disabled"}) + mpv_player = MPVPlayer( + empty_fn, empty_fn, empty_fn, empty_fn, {"Replay Gain": "Disabled"} + ) song_path = Path(__file__).parent.joinpath("mock_data/test-song.mp3") mpv_player.play_media(str(song_path), timedelta(seconds=10), None)