Player refactor works with MPV player

This commit is contained in:
Sumner Evans
2020-06-13 20:53:56 -06:00
parent a86cf7a4e3
commit 8df85cba96
11 changed files with 212 additions and 123 deletions

View File

@@ -651,6 +651,7 @@ class SubsonicAdapter(Adapter):
self._get( self._get(
self._make_url("savePlayQueue"), self._make_url("savePlayQueue"),
id=song_ids, id=song_ids,
timeout=2,
current=song_ids[current_song_index] current=song_ids[current_song_index]
if current_song_index is not None if current_song_index is not None
else None, else None,

View File

@@ -265,7 +265,39 @@ class SublimeMusicApp(Gtk.Application):
) )
def player_device_change_callback(event: PlayerDeviceEvent): 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( self.player_manager = PlayerManager(
on_timepos_change, on_timepos_change,
@@ -274,15 +306,8 @@ class SublimeMusicApp(Gtk.Application):
player_device_change_callback, player_device_change_callback,
self.app_config.player_config, self.app_config.player_config,
) )
self.player_manager.init_players()
if self.app_config.state.current_device != "this device": GLib.timeout_add(10000, check_if_connected)
# 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
# Update after Adapter Initial Sync # Update after Adapter Initial Sync
inital_sync_result = AdapterManager.initial_sync() inital_sync_result = AdapterManager.initial_sync()
@@ -311,7 +336,7 @@ class SublimeMusicApp(Gtk.Application):
connection, connection,
self.on_dbus_method_call, self.on_dbus_method_call,
self.on_dbus_set_property, self.on_dbus_set_property,
lambda: (self.app_config, self.player), lambda: (self.app_config, self.player_manager),
) )
return True return True
@@ -604,8 +629,8 @@ class SublimeMusicApp(Gtk.Application):
self.app_config.state.playing = not self.app_config.state.playing self.app_config.state.playing = not self.app_config.state.playing
if self.player.song_loaded: if self.player_manager.song_loaded:
self.player.toggle_play() self.player_manager.toggle_play()
self.save_play_queue() self.save_play_queue()
else: else:
# This is from a restart, start playing the file. # This is from a restart, start playing the file.
@@ -743,7 +768,7 @@ class SublimeMusicApp(Gtk.Application):
if self.app_config.state.playing: if self.app_config.state.playing:
self.on_play_pause() self.on_play_pause()
self.loading_state = True self.loading_state = True
self.player.reset() self.player_manager.reset()
AdapterManager.reset(self.app_config, self.on_song_download_progress) AdapterManager.reset(self.app_config, self.on_song_download_progress)
self.loading_state = False self.loading_state = False
@@ -838,20 +863,21 @@ class SublimeMusicApp(Gtk.Application):
) )
# If already playing, then make the player itself seek. # If already playing, then make the player itself seek.
if self.player and self.player.song_loaded: if self.player_manager and self.player_manager.song_loaded:
self.player.seek(new_time) self.player_manager.seek(new_time)
self.save_play_queue() self.save_play_queue()
def on_device_update(self, win: Any, device_uuid: str): 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: if device_uuid == self.app_config.state.current_device:
return return
self.app_config.state.current_device = device_uuid self.app_config.state.current_device = device_uuid
was_playing = self.app_config.state.playing was_playing = self.app_config.state.playing
self.player.pause() self.player_manager.pause()
self.player._song_loaded = False self.player_manager._song_loaded = False
self.app_config.state.playing = False self.app_config.state.playing = False
if self.dbus_manager: if self.dbus_manager:
@@ -873,14 +899,14 @@ class SublimeMusicApp(Gtk.Application):
@dbus_propagate() @dbus_propagate()
def on_mute_toggle(self, *args): def on_mute_toggle(self, *args):
self.app_config.state.is_muted = not self.app_config.state.is_muted 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() self.update_window()
@dbus_propagate() @dbus_propagate()
def on_volume_change(self, _, value: float): def on_volume_change(self, _, value: float):
assert self.player assert self.player_manager
self.app_config.state.volume = value 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() self.update_window()
def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey) -> bool: 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: if self.app_config.provider is None:
return return
if self.player: if self.player_manager:
self.player.pause() self.player_manager.pause()
self.chromecast_player.shutdown() self.player_manager.shutdown()
self.mpv_player.shutdown()
self.app_config.save() self.app_config.save()
self.save_play_queue()
if self.dbus_manager: if self.dbus_manager:
self.dbus_manager.shutdown() self.dbus_manager.shutdown()
AdapterManager.shutdown() AdapterManager.shutdown()
@@ -973,7 +997,7 @@ class SublimeMusicApp(Gtk.Application):
# prompt_confirm is False. # prompt_confirm is False.
if not prompt_confirm and self.app_config.state.playing: if not prompt_confirm and self.app_config.state.playing:
assert self.player assert self.player
self.player.pause() self.player_manager.pause()
self.app_config.state.playing = False self.app_config.state.playing = False
self.update_window() self.update_window()
@@ -1025,7 +1049,7 @@ class SublimeMusicApp(Gtk.Application):
self.app_config.state.current_song_index = ( self.app_config.state.current_song_index = (
play_queue.current_index or 0 play_queue.current_index or 0
) )
self.player.reset() self.player_manager.reset()
self.app_config.state.current_notification = None self.app_config.state.current_notification = None
self.update_window() self.update_window()
@@ -1053,7 +1077,7 @@ class SublimeMusicApp(Gtk.Application):
playable_song_search_direction: int = 1, playable_song_search_direction: int = 1,
): ):
def do_reset(): def do_reset():
self.player.reset() self.player_manager.reset()
self.app_config.state.song_progress = timedelta(0) self.app_config.state.song_progress = timedelta(0)
self.should_scrobble_song = True self.should_scrobble_song = True
@@ -1061,7 +1085,7 @@ class SublimeMusicApp(Gtk.Application):
# in the callback. # in the callback.
@dbus_propagate(self) @dbus_propagate(self)
def do_play_song(order_token: int, song: Song): def do_play_song(order_token: int, song: Song):
assert self.player assert self.player_manager
if order_token != self.song_playing_order_token: if order_token != self.song_playing_order_token:
return return
@@ -1076,7 +1100,7 @@ class SublimeMusicApp(Gtk.Application):
if order_token != self.song_playing_order_token: if order_token != self.song_playing_order_token:
return return
self.player.play_media( self.player_manager.play_media(
uri, uri,
timedelta(0) if reset else self.app_config.state.song_progress, timedelta(0) if reset else self.app_config.state.song_progress,
song, song,
@@ -1163,9 +1187,10 @@ class SublimeMusicApp(Gtk.Application):
# Switch to the local media if the player can hotswap without lag. # Switch to the local media if the player can hotswap without lag.
# For example, MPV can is barely noticable whereas there's quite a # For example, MPV can is barely noticable whereas there's quite a
# delay with Chromecast. # delay with Chromecast.
assert self.player assert self.player_manager
if self.player.can_hotswap_source: if self.player_manager.can_start_playing_with_no_latency:
self.player.play_media( print(self.player_manager)
self.player_manager.play_media(
AdapterManager.get_song_filename_or_stream(song), AdapterManager.get_song_filename_or_stream(song),
self.app_config.state.song_progress, self.app_config.state.song_progress,
song, song,

View File

@@ -190,7 +190,7 @@ class DBusManager:
def property_dict(self) -> Dict[str, Any]: def property_dict(self) -> Dict[str, Any]:
config, player_manager = self.get_config_and_player_manager() 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 {} return {}
state = config.state state = config.state
@@ -227,7 +227,7 @@ class DBusManager:
(False, True): "Stopped", (False, True): "Stopped",
(True, False): "Paused", (True, False): "Paused",
(True, True): "Playing", (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(), "LoopStatus": state.repeat_type.as_mpris_loop_status(),
"Rate": 1.0, "Rate": 1.0,
"Shuffle": state.shuffle_on, "Shuffle": state.shuffle_on,

View File

@@ -66,6 +66,18 @@ class PlayerEvent:
device_id: Optional[str] = None 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): class Player(abc.ABC):
@property @property
@abc.abstractmethod @abc.abstractmethod
@@ -107,6 +119,7 @@ class Player(abc.ABC):
on_timepos_change: Callable[[Optional[float]], None], on_timepos_change: Callable[[Optional[float]], None],
on_track_end: Callable[[], None], on_track_end: Callable[[], None],
on_player_event: Callable[[PlayerEvent], None], on_player_event: Callable[[PlayerEvent], None],
player_device_change_callback: Callable[[PlayerDeviceEvent], None],
config: Dict[str, Union[str, int, bool]], config: Dict[str, Union[str, int, bool]],
): ):
""" """
@@ -126,12 +139,6 @@ class Player(abc.ABC):
Do any cleanup of the player. 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 @property
@abc.abstractmethod @abc.abstractmethod
def playing(self) -> bool: def playing(self) -> bool:

View File

@@ -23,7 +23,7 @@ from urllib.parse import urlparse
from sublime.adapters import AdapterManager from sublime.adapters import AdapterManager
from sublime.adapters.api_objects import Song from sublime.adapters.api_objects import Song
from .base import Player, PlayerEvent from .base import Player, PlayerDeviceEvent, PlayerEvent
try: try:
import pychromecast import pychromecast
@@ -68,6 +68,7 @@ class ChromecastPlayer(Player):
on_timepos_change: Callable[[Optional[float]], None], on_timepos_change: Callable[[Optional[float]], None],
on_track_end: Callable[[], None], on_track_end: Callable[[], None],
on_player_event: Callable[[PlayerEvent], None], on_player_event: Callable[[PlayerEvent], None],
player_device_change_callback: Callable[[PlayerDeviceEvent], None],
config: Dict[str, Union[str, int, bool]], config: Dict[str, Union[str, int, bool]],
): ):
self.server_process = None self.server_process = None
@@ -78,15 +79,33 @@ class ChromecastPlayer(Player):
args=("0.0.0.0", self.config.get(LAN_PORT_KEY)), args=("0.0.0.0", self.config.get(LAN_PORT_KEY)),
) )
self._stop_retrieve_chromecasts = None
if chromecast_imported: if chromecast_imported:
self._chromecasts: List[Any] = [] self._chromecasts: Dict[str, pychromecast.Chromecast] = {}
self._current_chromecast = 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): def shutdown(self):
if self._current_chromecast: if self._current_chromecast:
self._current_chromecast.disconnect() self._current_chromecast.disconnect()
if self.server_process: if self.server_process:
self.server_process.terminate() 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_song_id = multiprocessing.Array("c", 1024) # huge buffer, just in case
_serving_token = multiprocessing.Array("c", 12) _serving_token = multiprocessing.Array("c", 12)
@@ -121,14 +140,6 @@ class ChromecastPlayer(Player):
bottle.run(app, host=host, port=port) 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 @property
def playing(self) -> bool: def playing(self) -> bool:
if ( if (

View File

@@ -1,6 +1,7 @@
import logging import logging
import multiprocessing import multiprocessing
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta
from enum import Enum from enum import Enum
from functools import partial from functools import partial
from time import sleep from time import sleep
@@ -17,27 +18,18 @@ from typing import (
Union, Union,
) )
from sublime.adapters.api_objects import Song
from .base import PlayerEvent from .base import PlayerDeviceEvent, PlayerEvent
from .chromecast import ChromecastPlayer # noqa: F401 from .chromecast import ChromecastPlayer # noqa: F401
from .mpv import MPVPlayer # 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: class PlayerManager:
# Available Players. Order matters for UI display. # Available Players. Order matters for UI display.
available_player_types: List[Type] = [MPVPlayer, ChromecastPlayer] available_player_types: List[Type] = [MPVPlayer, ChromecastPlayer]
# TODO
# player_device_retrieval_process: Optional[multiprocessing.Process] = None
@staticmethod @staticmethod
def get_configuration_options() -> Dict[ def get_configuration_options() -> Dict[
@@ -64,62 +56,105 @@ class PlayerManager:
self.on_timepos_change = on_timepos_change self.on_timepos_change = on_timepos_change
self.on_track_end = on_track_end self.on_track_end = on_track_end
self.on_player_event = on_player_event 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 = [ def callback_wrapper(pde: PlayerDeviceEvent):
player_type( self.device_id_type_map[pde.id] = pde.player_type
on_timepos_change, player_device_change_callback(pde)
on_track_end,
on_player_event, self.player_device_change_callback = callback_wrapper
config.get(player_type.name),
# 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 for player_type in PlayerManager.available_player_types
] }
self.device_id_type_map: Dict[str, Type] = {} has_done_one_retrieval = multiprocessing.Value("b", False)
self.player_device_change_callback = player_device_change_callback
self.player_device_retrieval_process = multiprocessing.Process(
target=self._retrieve_available_player_devices
)
def shutdown(self): def shutdown(self):
print("SHUTDOWN PLAYER MANAGER") pass
self.player_device_retrieval_process.terminate()
def _retrieve_available_player_devices(self): def _get_current_player_type(self) -> Any:
seen_ids = set() if device_id := self._current_device_id:
while True: return self.device_id_type_map.get(device_id)
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
for t, device_id in seen_ids.difference(new_ids): def _get_current_player(self) -> Any:
self.player_device_change_callback( if current_player_type := self._get_current_player_type():
PlayerDeviceEvent(PlayerDeviceEvent.Delta.REMOVE, t, device_id) return self.players.get(current_player_type)
)
del self.device_id_type_map[device_id]
seen_ids = new_ids @property
sleep(15) def can_start_playing_with_no_latency(self) -> bool:
if self._current_device_id:
def can_start_playing_with_no_latency(self, device_id: str) -> bool: return self._get_current_player_type().can_start_playing_with_no_latency
return self.device_id_type_map[device_id].can_start_playing_with_no_latency else:
return False
_current_device_id = None
@property @property
def current_device_id(self) -> Optional[str]: def current_device_id(self) -> Optional[str]:
return self._current_device_id return self._current_device_id
@current_device_id.setter def set_current_device_id(self, device_id: str):
def current_device_id(self, value: str): logging.info(f"Setting current device id to '{device_id}'")
print("SET CURRENT DEVICE") self._current_device_id = device_id
self._current_device_id = value
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)

View File

@@ -17,7 +17,7 @@ import mpv
from sublime.adapters.api_objects import Song from sublime.adapters.api_objects import Song
from .base import Player, PlayerEvent from .base import Player, PlayerDeviceEvent, PlayerEvent
REPLAY_GAIN_KEY = "Replay Gain" REPLAY_GAIN_KEY = "Replay Gain"
@@ -44,6 +44,7 @@ class MPVPlayer(Player):
on_timepos_change: Callable[[Optional[float]], None], on_timepos_change: Callable[[Optional[float]], None],
on_track_end: Callable[[], None], on_track_end: Callable[[], None],
on_player_event: Callable[[PlayerEvent], None], on_player_event: Callable[[PlayerEvent], None],
player_device_change_callback: Callable[[PlayerDeviceEvent], None],
config: Dict[str, Union[str, int, bool]], config: Dict[str, Union[str, int, bool]],
): ):
self.mpv = mpv.MPV() 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): def shutdown(self):
pass pass
@@ -83,9 +91,6 @@ class MPVPlayer(Player):
with self._progress_value_lock: with self._progress_value_lock:
self._progress_value_count = 0 self._progress_value_count = 0
def get_available_player_devices(self) -> Iterator[Tuple[str, str]]:
yield ("this device", "This Device")
@property @property
def playing(self) -> bool: def playing(self) -> bool:
return not self.mpv.pause return not self.mpv.pause

View File

@@ -196,13 +196,14 @@ class MainWindow(Gtk.ApplicationWindow):
# Main Settings # Main Settings
self.notification_switch.set_active(app_config.song_play_notification) self.notification_switch.set_active(app_config.song_play_notification)
# MPV Settings # TODO
self.replay_gain_options.set_active_id(app_config.replay_gain.as_string()) # # MPV Settings
# self.replay_gain_options.set_active_id(app_config.replay_gain.as_string())
# Chromecast Settings # # Chromecast Settings
self.serve_over_lan_switch.set_active(app_config.serve_over_lan) # 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_value(app_config.port_number)
self.port_number_entry.set_sensitive(app_config.serve_over_lan) # self.port_number_entry.set_sensitive(app_config.serve_over_lan)
# Download Settings # Download Settings
allow_song_downloads = app_config.allow_song_downloads allow_song_downloads = app_config.allow_song_downloads

View File

@@ -63,6 +63,7 @@ class UIState:
song_progress: timedelta = timedelta() song_progress: timedelta = timedelta()
song_stream_cache_progress: Optional[timedelta] = timedelta() song_stream_cache_progress: Optional[timedelta] = timedelta()
current_device: str = "this device" current_device: str = "this device"
connecting_to_device: bool = False
# UI state # UI state
current_tab: str = "albums" current_tab: str = "albums"

View File

@@ -7,6 +7,7 @@ def test_init():
empty_fn, empty_fn,
empty_fn, empty_fn,
empty_fn, empty_fn,
empty_fn,
{ {
"Serve Local Files to Chromecasts on the LAN": True, "Serve Local Files to Chromecasts on the LAN": True,
"LAN Server Port Number": 6969, "LAN Server Port Number": 6969,

View File

@@ -7,7 +7,7 @@ from sublime.players.mpv import MPVPlayer
def test_init(): def test_init():
empty_fn = lambda *a, **k: None 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: 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(): def test_play():
empty_fn = lambda *a, **k: None 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") song_path = Path(__file__).parent.joinpath("mock_data/test-song.mp3")
mpv_player.play_media(str(song_path), timedelta(seconds=10), None) mpv_player.play_media(str(song_path), timedelta(seconds=10), None)