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._make_url("savePlayQueue"),
id=song_ids,
timeout=2,
current=song_ids[current_song_index]
if current_song_index is not None
else None,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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:

View File

@@ -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 (

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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,

View File

@@ -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)