From f053a4ddb98782398f5f43aafa54491a0efaa1e9 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 12 Jun 2020 13:31:19 -0600 Subject: [PATCH] Continuing the player refactor --- Pipfile | 2 +- Pipfile.lock | 42 +++---- sublime/app.py | 27 +++-- sublime/config.py | 48 ++++---- sublime/dbus/manager.py | 10 +- sublime/players/base.py | 154 +++++++++++++++---------- sublime/players/chromecast.py | 125 ++++++++++++++++++-- sublime/players/manager.py | 7 +- sublime/players/mpv.py | 89 +++++++++++--- sublime/ui/main.py | 2 +- sublime/ui/player_controls.py | 5 +- tests/config_test.py | 43 +++---- tests/mock_data/config-v5.json | 17 +++ tests/player_tests/chromecast_tests.py | 14 +++ tests/player_tests/mpv_tests.py | 6 + 15 files changed, 408 insertions(+), 183 deletions(-) create mode 100644 tests/mock_data/config-v5.json create mode 100644 tests/player_tests/chromecast_tests.py create mode 100644 tests/player_tests/mpv_tests.py diff --git a/Pipfile b/Pipfile index 1093f8b..ea6af8b 100644 --- a/Pipfile +++ b/Pipfile @@ -26,7 +26,7 @@ sphinx-rtd-theme = "*" termcolor = "*" [packages] -sublime-music = {editable = true,extras = ["chromecast", "keyring", "server"],path = "."} +sublime-music = {editable = true, extras = ["chromecast", "keyring", "server"], path = "."} [requires] python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index c3fe6f9..e06d54f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "818f55683bf6393f7a95869ffb5ba553f2e47f5ffd079dcb49accf3a4769d91f" + "sha256": "f018bc2d21d6dc296af872daca484440360496dc7fd3746880da2fe2996ed0ce" }, "pipfile-spec": 6, "requires": { @@ -263,22 +263,6 @@ ], "version": "==0.4.6" }, - "pyyaml": { - "hashes": [ - "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", - "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", - "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", - "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", - "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", - "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", - "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", - "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", - "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", - "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", - "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" - ], - "version": "==5.3.1" - }, "requests": { "hashes": [ "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", @@ -313,7 +297,8 @@ "editable": true, "extras": [ "chromecast", - "keyring" + "keyring", + "server" ], "path": "." }, @@ -345,7 +330,8 @@ "editable": true, "extras": [ "chromecast", - "keyring" + "keyring", + "server" ], "path": "." } @@ -458,11 +444,11 @@ }, "flake8": { "hashes": [ - "sha256:c69ac1668e434d37a2d2880b3ca9aafd54b3a10a3ac1ab101d22f29e29cf8634", - "sha256:ccaa799ef9893cebe69fdfefed76865aeaefbb94cb8545617b2298786a4de9a5" + "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c", + "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208" ], "index": "pypi", - "version": "==3.8.2" + "version": "==3.8.3" }, "flake8-annotations": { "hashes": [ @@ -815,11 +801,11 @@ }, "sphinx": { "hashes": [ - "sha256:779a519adbd3a70fc7c468af08c5e74829868b0a5b34587b33340e010291856c", - "sha256:ea64df287958ee5aac46be7ac2b7277305b0381d213728c3a49d8bb9b8415807" + "sha256:1c445320a3310baa5ccb8d957267ef4a0fc930dc1234db5098b3d7af14fbb242", + "sha256:7d3d5087e39ab5a031b75588e9859f011de70e213cd0080ccbc28079fb0786d1" ], "index": "pypi", - "version": "==3.0.4" + "version": "==3.1.0" }, "sphinx-rtd-theme": { "hashes": [ @@ -935,10 +921,10 @@ }, "wcwidth": { "hashes": [ - "sha256:980fbf4f3c196c0f329cdcd1e84c554d6a211f18e252e525a0cf4223154a41d6", - "sha256:edbc2b718b4db6cdf393eefe3a420183947d6aa312505ce6754516f458ff8830" + "sha256:79375666b9954d4a1a10739315816324c3e73110af9d0e102d906fdb0aec009f", + "sha256:8c6b5b6ee1360b842645f336d9e5d68c55817c26d3050f46b235ef2bc650e48f" ], - "version": "==0.2.3" + "version": "==0.2.4" } } } diff --git a/sublime/app.py b/sublime/app.py index 8886cbc..12dd86a 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -42,7 +42,7 @@ from .adapters import ( from .adapters.api_objects import Playlist, PlayQueue, Song from .config import AppConfiguration, ProviderConfiguration from .dbus import dbus_propagate, DBusManager -from .players import ChromecastPlayer, MPVPlayer, Player, PlayerEvent +from .players import PlayerDeviceEvent, PlayerEvent, PlayerManager from .ui.configure_provider import ConfigureProviderDialog from .ui.main import MainWindow from .ui.state import RepeatType, UIState @@ -60,7 +60,7 @@ class SublimeMusicApp(Gtk.Application): self.connect("shutdown", self.on_app_shutdown) - player: Optional[Player] = None + player_manager: Optional[PlayerManager] = None exiting: bool = False def do_startup(self): @@ -188,7 +188,7 @@ class SublimeMusicApp(Gtk.Application): self.loading_state = False self.should_scrobble_song = False - def time_observer(value: Optional[float]): + def on_timepos_change(value: Optional[float]): if ( self.loading_state or not self.window @@ -234,19 +234,19 @@ class SublimeMusicApp(Gtk.Application): GLib.idle_add(self.on_next_track) def on_player_event(event: PlayerEvent): - if event.type == PlayerEvent.Type.PLAY_STATE_CHANGE: + if event.type == PlayerEvent.EventType.PLAY_STATE_CHANGE: assert event.playing is not None self.app_config.state.playing = event.playing if self.dbus_manager: self.dbus_manager.property_diff() self.update_window() - elif event.type == PlayerEvent.Type.VOLUME_CHANGE: + elif event.type == PlayerEvent.EventType.VOLUME_CHANGE: assert event.volume is not None self.app_config.state.volume = event.volume if self.dbus_manager: self.dbus_manager.property_diff() self.update_window() - elif event.type == PlayerEvent.Type.STREAM_CACHE_PROGRESS_CHANGE: + elif event.type == PlayerEvent.EventType.STREAM_CACHE_PROGRESS_CHANGE: if ( self.loading_state or not self.window @@ -264,13 +264,16 @@ class SublimeMusicApp(Gtk.Application): self.app_config.state.song_stream_cache_progress, ) - self.mpv_player = MPVPlayer( - time_observer, on_track_end, on_player_event, self.app_config, + def player_device_change_callback(event: PlayerDeviceEvent): + pass + + self.player_manager = PlayerManager( + on_timepos_change, + on_track_end, + on_player_event, + player_device_change_callback, + self.app_config.player_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) attempt to connect to the previously connected device diff --git a/sublime/config.py b/sublime/config.py index d60a3b2..0f3ecb3 100644 --- a/sublime/config.py +++ b/sublime/config.py @@ -4,7 +4,7 @@ import pickle from dataclasses import dataclass, field from enum import Enum from pathlib import Path -from typing import Any, cast, Dict, Optional, Type +from typing import Any, cast, Dict, Optional, Type, Tuple, Union import dataclasses_json from dataclasses_json import config, DataClassJsonMixin @@ -27,25 +27,6 @@ dataclasses_json.cfg.global_config.encoders[Path] = encode_path dataclasses_json.cfg.global_config.encoders[Optional[Path]] = encode_path -# TODO get rid of this -class ReplayGainType(Enum): - NO = 0 - TRACK = 1 - ALBUM = 2 - - def as_string(self) -> str: - return ["no", "track", "album"][self.value] - - @staticmethod - def from_string(replay_gain_type: str) -> "ReplayGainType": - return { - "no": ReplayGainType.NO, - "disabled": ReplayGainType.NO, - "track": ReplayGainType.TRACK, - "album": ReplayGainType.ALBUM, - }[replay_gain_type.lower()] - - @dataclass class ProviderConfiguration: id: str @@ -128,19 +109,23 @@ class AppConfiguration(DataClassJsonMixin): current_provider_id: Optional[str] = None _loaded_provider_id: Optional[str] = field(default=None, init=False) + # Players + player_config: Dict[str, Dict[str, Union[Type, Tuple[str, ...]]]] = field( + default_factory=dict + ) + # Global Settings song_play_notification: bool = True offline_mode: bool = False - serve_over_lan: bool = True - port_number: int = 8282 - replay_gain: ReplayGainType = ReplayGainType.NO allow_song_downloads: bool = True download_on_stream: bool = True # also download when streaming a song prefetch_amount: int = 3 concurrent_download_limit: int = 5 - # Deprecated - always_stream: bool = False # always stream instead of downloading songs + # Deprecated. These have also been renamed to avoid using them elsewhere in the app. + _sol: bool = field(default=True, metadata=config(field_name="serve_over_lan")) + _pn: int = field(default=8282, metadata=config(field_name="port_number")) + _rg: int = field(default=0, metadata=config(field_name="replay_gain")) @staticmethod def load_from_file(filename: Path) -> "AppConfiguration": @@ -170,7 +155,18 @@ class AppConfiguration(DataClassJsonMixin): for _, provider in self.providers.items(): provider.migrate() - self.version = 5 + if self.version < 6: + self.player_config = { + "Local Playback": { + "Replay Gain": ["no", "track", "album"][self._rg] + }, + "Chromecast": { + "Serve Local Files to Chromecasts on the LAN": self._sol, + "LAN Server Port Number": self._pn, + }, + } + + self.version = 6 self.state.migrate() @property diff --git a/sublime/dbus/manager.py b/sublime/dbus/manager.py index 8ad5721..94cc3b5 100644 --- a/sublime/dbus/manager.py +++ b/sublime/dbus/manager.py @@ -11,7 +11,7 @@ from gi.repository import Gio, GLib from sublime.adapters import AdapterManager, CacheMissError from sublime.config import AppConfiguration -from sublime.players import Player +from sublime.players import PlayerManager from sublime.ui.state import RepeatType @@ -53,9 +53,11 @@ class DBusManager: on_set_property: Callable[ [Gio.DBusConnection, str, str, str, str, GLib.Variant], None ], - get_config_and_player: Callable[[], Tuple[AppConfiguration, Optional[Player]]], + get_config_and_player_manager: Callable[ + [], Tuple[AppConfiguration, Optional[PlayerManager]] + ], ): - self.get_config_and_player = get_config_and_player + self.get_config_and_player_manager = get_config_and_player_manager self.do_on_method_call = do_on_method_call self.on_set_property = on_set_property self.connection = connection @@ -187,7 +189,7 @@ class DBusManager: return DBusManager._escape_re.sub(replace, id) def property_dict(self) -> Dict[str, Any]: - config, player = self.get_config_and_player() + config, player_manager = self.get_config_and_player_manager() if config is None or player is None: return {} diff --git a/sublime/players/base.py b/sublime/players/base.py index 6b58412..e5d5dca 100644 --- a/sublime/players/base.py +++ b/sublime/players/base.py @@ -1,6 +1,7 @@ import abc import multiprocessing from dataclasses import dataclass +from datetime import timedelta from enum import Enum from functools import partial from time import sleep @@ -11,14 +12,46 @@ from typing import ( Iterator, List, Optional, + Set, Tuple, Type, Union, ) +from sublime.adapters.api_objects import Song + @dataclass class PlayerEvent: + """ + Represents an event triggered by the player. This is a way to signal state changes + to Sublime Music if the player can be controlled outside of Sublime Music (for + example, Chromecast player). + + Each player event has a :class:`PlayerEvent.EventType`. Additionally, each event + type has additional information in the form of additional properties on the + :class:`PlayerEvent` object. + + * :class:`PlayerEvent.EventType.PLAY_STATE_CHANGE` -- indicates that the play state + of the player has changed. The :class:`PlayerEvent.playing` property is required + for this event type. + * :class:`PlayerEvent.EventType.VOLUME_CHANGE` -- indicates that the player's volume + has changed. The :classs`PlayerEvent.volume` property is required for this event + type and should be in the range [0, 100]. + * :class:`PlayerEvent.EventType.STREAM_CACHE_PROGRESS_CHANGE` -- indicates that the + stream cache progress has changed. When streaming a song, this will be used to + show how much of the song has been loaded into the player. The + :class:`PlayerEvent.stream_cache_duration` property is required for this event + type and should be a float represent the number of seconds of the song that have + been cached. + * :class:`PlayerEvent.EventType.CONNECTING` -- indicates that a device is being + connected to. The :class:`PlayerEvent.device_id` property is required for this + event type and indicates the device ID that is being connected to. + * :class:`PlayerEvent.EventType.CONNECTED` -- indicates that a device has been + connected to. The :class:`PlayerEvent.device_id` property is required for this + event type and indicates the device ID that has been connected to. + """ + class EventType(Enum): PLAY_STATE_CHANGE = 0 VOLUME_CHANGE = 1 @@ -30,6 +63,7 @@ class PlayerEvent: playing: Optional[bool] = None volume: Optional[float] = None stream_cache_duration: Optional[float] = None + device_id: Optional[str] = None class Player(abc.ABC): @@ -45,6 +79,13 @@ class Player(abc.ABC): :returns: returns the friendly name of the player for display in the UI. """ + @property + @abc.abstractmethod + def supported_schemes(self) -> Set[str]: + """ + :returns: a set of all the schemes that the player can play. + """ + @property def can_start_playing_with_no_latency(self) -> bool: """ @@ -61,11 +102,24 @@ class Player(abc.ABC): """ @abc.abstractmethod - def __init__(self, config: Dict[str, Union[str, int, bool]]): + def __init__( + self, + on_timepos_change: Callable[[Optional[float]], None], + on_track_end: Callable[[], None], + on_player_event: Callable[[PlayerEvent], None], + config: Dict[str, Union[str, int, bool]], + ): """ + Initialize the player. + :param config: A dictionary of configuration key -> configuration value """ + def reset(self): + """ + Reset the player. + """ + @abc.abstractmethod def shutdown(self): """ @@ -86,84 +140,62 @@ class Player(abc.ABC): """ @property - @abc.abstractmethod def song_loaded(self) -> bool: """ :returns: whether or not the player currently has a song loaded. """ + return False - @property @abc.abstractmethod - def volume(self) -> float: + def get_volume(self) -> float: """ :returns: the current volume on a scale of [0, 100] """ - @volume.setter @abc.abstractmethod - def volume(self, value: float): + def set_volume(self, volume: float): + """ + Set the volume of the player to the given value. + + :param volume: the value to set the volume to. Will be in the range [0, 100] """ + @abc.abstractmethod + def get_is_muted(self) -> bool: + """ + :returns: whether or not the player is muted. """ - @property - def is_muted(self) -> bool: - return self._get_is_muted() + @abc.abstractmethod + def set_muted(self, muted: bool): + """ + :param muted: set the player's "muted" property to the given value. + """ - @is_muted.setter - def is_muted(self, value: bool): - self._set_is_muted(value) - - def reset(self): - raise NotImplementedError("reset must be implemented by implementor of Player") - - def play_media(self, file_or_url: str, progress: timedelta, song: Song): - raise NotImplementedError( - "play_media must be implemented by implementor of Player" - ) - - def _is_playing(self): - raise NotImplementedError( - "_is_playing must be implemented by implementor of Player" - ) + @abc.abstractmethod + def play_media(self, uri: str, progress: timedelta, song: Song): + """ + :param uri: the URI to play. The URI is guaranteed to be one of the schemes in + the :class:`supported_schemes` set for this adapter. + :param progress: the time at which to start playing the song. + :param song: the actual song. This could be used to set metadata and such on the + player. + """ + @abc.abstractmethod def pause(self): - raise NotImplementedError("pause must be implemented by implementor of Player") + """ + Pause the player. + """ + @abc.abstractmethod def toggle_play(self): - raise NotImplementedError( - "toggle_play must be implemented by implementor of Player" - ) + """ + Toggle the play state of the player. + """ + # TODO can we get rid of this? - def seek(self, value: timedelta): - raise NotImplementedError("seek must be implemented by implementor of Player") - - def _get_timepos(self): - raise NotImplementedError( - "get_timepos must be implemented by implementor of Player" - ) - - def _get_volume(self): - raise NotImplementedError( - "_get_volume must be implemented by implementor of Player" - ) - - def _set_volume(self, value: float): - raise NotImplementedError( - "_set_volume must be implemented by implementor of Player" - ) - - def _get_is_muted(self): - raise NotImplementedError( - "_get_is_muted must be implemented by implementor of Player" - ) - - def _set_is_muted(self, value: bool): - raise NotImplementedError( - "_set_is_muted must be implemented by implementor of Player" - ) - - def shutdown(self): - raise NotImplementedError( - "shutdown must be implemented by implementor of Player" - ) + def seek(self, position: timedelta): + """ + :param position: seek to the given position in the song. + """ diff --git a/sublime/players/chromecast.py b/sublime/players/chromecast.py index 26a7303..347b52d 100644 --- a/sublime/players/chromecast.py +++ b/sublime/players/chromecast.py @@ -1,6 +1,10 @@ +import base64 import io import mimetypes import multiprocessing +import os +import socket +from datetime import timedelta from typing import ( Any, Callable, @@ -10,13 +14,16 @@ from typing import ( Iterator, List, Optional, + Set, Tuple, Type, ) +from urllib.parse import urlparse from sublime.adapters import AdapterManager +from sublime.adapters.api_objects import Song -from .base import Player +from .base import Player, PlayerEvent try: import pychromecast @@ -40,30 +47,49 @@ class ChromecastPlayer(Player): name = "Chromecast" can_start_playing_with_no_latency = False + @property + def enabled(self) -> bool: + return chromecast_imported + @staticmethod def get_configuration_options() -> Dict[str, Union[Type, Tuple[str, ...]]]: if not bottle_imported: return {} return {SERVE_FILES_KEY: bool, LAN_PORT_KEY: int} - def __init__(self, config: Dict[str, Union[str, int, bool]]): - self.supported_schemes = {"http", "https"} + def supported_schemes(self) -> Set[str]: + schemes = {"http", "https"} + if bottle_imported and self.config.get(SERVE_FILES_KEY): + schemes.add("file") + return schemes + + def __init__( + self, + on_timepos_change: Callable[[Optional[float]], None], + on_track_end: Callable[[], None], + on_player_event: Callable[[PlayerEvent], None], + config: Dict[str, Union[str, int, bool]], + ): self.server_process = None - if bottle_imported and config.get(SERVE_FILES_KEY): - self.supported_schemes.add("file") + self.config = config + if bottle_imported and self.config.get(SERVE_FILES_KEY): self.server_process = multiprocessing.Process( target=self._run_server_process, - args=("0.0.0.0", config.get(LAN_PORT_KEY)), + args=("0.0.0.0", self.config.get(LAN_PORT_KEY)), ) - self._chromecasts: List[Any] = [] + if chromecast_imported: + self._chromecasts: List[Any] = [] + self._current_chromecast = pychromecast.Chromecast def shutdown(self): + if self._current_chromecast: + self._current_chromecast.disconnect() if self.server_process: self.server_process.terminate() - _serving_song_id = multiprocessing.Value("s") - _serving_token = multiprocessing.Value("s") + _serving_song_id = multiprocessing.Array("c", 1024) # huge buffer, just in case + _serving_token = multiprocessing.Array("c", 12) def _run_server_process(self, host: str, port: int): app = bottle.Bottle() @@ -102,3 +128,84 @@ class ChromecastPlayer(Player): 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 ( + not self._current_chromecast + or not self._current_chromecast.media_controller + ): + return False + return self._current_chromecast.media_controller.player_is_playing + + def get_volume(self) -> float: + if self._current_chromecast: + # The volume is in the range [0, 1]. Multiply by 100 to get to [0, 100]. + return self._current_chromecast.status.volume_level * 100 + else: + return 100 + + def set_volume(self, volume: float): + if self._current_chromecast: + # volume value is in [0, 100]. Convert to [0, 1] for Chromecast. + self._current_chromecast.set_volume(volume / 100) + + def get_is_muted(self) -> bool: + return self._current_chromecast.volume_muted + + def set_muted(self, muted: bool): + self._current_chromecast.set_volume_muted(muted) + + def play_media(self, uri: str, progress: timedelta, song: Song): + scheme = urlparse(uri).scheme + if scheme == "file": + token = base64.b64encode(os.urandom(8)).decode("ascii") + for r in (("+", "."), ("/", "-"), ("=", "_")): + token = token.replace(*r) + self._serving_token.value = token + self._serving_song_id.value = song.id + + # If this fails, then we are basically screwed, so don't care if it blows + # up. + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + host_ip = s.getsockname()[0] + s.close() + + uri = f"http://{host_ip}:{self.config.get(LAN_PORT_KEY)}/s/{token}" + + cover_art_url = AdapterManager.get_cover_art_uri(song.cover_art, size=1000) + self._current_chromecast.media_controller.play_media( + uri, + # Just pretend that whatever we send it is mp3, even if it isn't. + "audio/mp3", + current_time=progress.total_seconds(), + title=song.title, + thumb=cover_art_url, + metadata={ + "metadataType": 3, + "albumName": song.album.name if song.album else None, + "artist": song.artist.name if song.artist else None, + "trackNumber": song.track, + }, + ) + + def pause(self): + if self._current_chromecast and self._current_chromecast.media_controller: + self._current_chromecast.media_controller.pause() + + def toggle_play(self): + if self.playing: + self._current_chromecast.media_controller.pause() + else: + self._current_chromecast.media_controller.play() + # self._wait_for_playing(self._start_time_incrementor) + + def seek(self, position: timedelta): + do_pause = not self.playing + self._current_chromecast.media_controller.seek(position.total_seconds()) + if do_pause: + self.pause() + + def _wait_for_playing(self): + pass diff --git a/sublime/players/manager.py b/sublime/players/manager.py index 15702b1..68845e6 100644 --- a/sublime/players/manager.py +++ b/sublime/players/manager.py @@ -66,7 +66,12 @@ class PlayerManager: self.on_player_event = on_player_event self.players = [ - player_type(config.get(player_type.name)) + player_type( + on_timepos_change, + on_track_end, + on_player_event, + config.get(player_type.name), + ) for player_type in PlayerManager.available_player_types ] diff --git a/sublime/players/mpv.py b/sublime/players/mpv.py index 35359b4..ddfca1f 100644 --- a/sublime/players/mpv.py +++ b/sublime/players/mpv.py @@ -1,4 +1,5 @@ import threading +from datetime import timedelta from typing import ( Callable, cast, @@ -14,7 +15,9 @@ from typing import ( import mpv -from .base import Player +from sublime.adapters.api_objects import Song + +from .base import Player, PlayerEvent REPLAY_GAIN_KEY = "Replay Gain" @@ -24,43 +27,50 @@ class MPVPlayer(Player): name = "Local Playback" can_start_playing_with_no_latency = True supported_schemes = {"http", "https", "file"} + song_loaded = False + + _progress_value_lock = threading.Lock() + _progress_value_count = 0 + + _volume = 100.0 + _muted = False @staticmethod def get_configuration_options() -> Dict[str, Union[Type, Tuple[str, ...]]]: return {REPLAY_GAIN_KEY: ("Disabled", "Track", "Album")} - def __init__(self, config: Dict[str, Union[str, int, bool]]): + def __init__( + self, + on_timepos_change: Callable[[Optional[float]], None], + on_track_end: Callable[[], None], + on_player_event: Callable[[PlayerEvent], None], + config: Dict[str, Union[str, int, bool]], + ): self.mpv = mpv.MPV() self.mpv.audio_client_name = "sublime-music" self.mpv.replaygain = { "Disabled": "no", "Track": "track", "Album": "album", - }.get(cast(str, config.get(REPLAY_GAIN_KEY, "Disabled"))) - - self.progress_value_lock = threading.Lock() - self.progress_value_count = 0 - self._muted = False - self._volume = 100.0 - self._can_hotswap_source = True + }.get(cast(str, config.get(REPLAY_GAIN_KEY, "Disabled")), "no") @self.mpv.property_observer("time-pos") def time_observer(_, value: Optional[float]): - self.on_timepos_change(value) - if value is None and self.progress_value_count > 1: - self.on_track_end() - with self.progress_value_lock: - self.progress_value_count = 0 + on_timepos_change(value) + if value is None and self._progress_value_count > 1: + on_track_end() + with self._progress_value_lock: + self._progress_value_count = 0 if value: - with self.progress_value_lock: - self.progress_value_count += 1 + with self._progress_value_lock: + self._progress_value_count += 1 @self.mpv.property_observer("demuxer-cache-time") def cache_size_observer(_, value: Optional[float]): on_player_event( PlayerEvent( - PlayerEvent.Type.STREAM_CACHE_PROGRESS_CHANGE, + PlayerEvent.EventType.STREAM_CACHE_PROGRESS_CHANGE, stream_cache_duration=value, ) ) @@ -68,5 +78,50 @@ class MPVPlayer(Player): def shutdown(self): pass + def reset(self): + self.song_loaded = False + 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 + + def get_volume(self) -> float: + return self._volume + + def set_volume(self, volume: float): + self.mpv.volume = volume + self._volume = volume + + def get_is_muted(self) -> bool: + return self._muted + + def set_muted(self, muted: bool): + self.mpv.volume = 0 if muted else self._volume + self._muted = muted + + def play_media(self, uri: str, progress: timedelta, song: Song): + with self._progress_value_lock: + self._progress_value_count = 0 + + options = {"force-seekable": "yes"} + if progress is not None: + options["start"] = str(progress.total_seconds()) + self.mpv.command( + "loadfile", uri, "replace", ",".join(f"{k}={v}" for k, v in options.items()) + ) + self.mpv.pause = False + self.song_loaded = True + + def pause(self): + self.mpv.pause = True + + def toggle_play(self): + self.mpv.cycle("pause") + + def seek(self, position: timedelta): + self.mpv.seek(str(position.total_seconds()), "absolute") diff --git a/sublime/ui/main.py b/sublime/ui/main.py index 938a095..aa729b6 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -9,7 +9,7 @@ from sublime.adapters import ( DownloadProgress, Result, ) -from sublime.config import AppConfiguration, ProviderConfiguration, ReplayGainType +from sublime.config import AppConfiguration, ProviderConfiguration from sublime.ui import albums, artists, browse, player_controls, playlists, util from sublime.ui.common import IconButton, IconMenuButton, SpinnerImage diff --git a/sublime/ui/player_controls.py b/sublime/ui/player_controls.py index b4a3cd0..f8212f0 100644 --- a/sublime/ui/player_controls.py +++ b/sublime/ui/player_controls.py @@ -10,7 +10,8 @@ from pychromecast import Chromecast from sublime.adapters import AdapterManager, Result, SongCacheStatus from sublime.adapters.api_objects import Song from sublime.config import AppConfiguration -from sublime.players import ChromecastPlayer +# TODO +# from sublime.players import ChromecastPlayer from sublime.ui import util from sublime.ui.common import IconButton, IconToggleButton, SpinnerImage from sublime.ui.state import RepeatType @@ -44,7 +45,7 @@ class PlayerControls(Gtk.ActionBar): current_device = None current_playing_index: Optional[int] = None current_play_queue: Tuple[str, ...] = () - chromecasts: List[ChromecastPlayer] = [] + # chromecasts: List[ChromecastPlayer] = [] cover_art_update_order_token = 0 play_queue_update_order_token = 0 devices_requested = False diff --git a/tests/config_test.py b/tests/config_test.py index 9615123..35e7aeb 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -1,3 +1,4 @@ +import shutil from pathlib import Path import pytest @@ -5,7 +6,7 @@ import pytest from sublime.adapters import ConfigurationStore from sublime.adapters.filesystem import FilesystemAdapter from sublime.adapters.subsonic import SubsonicAdapter -from sublime.config import AppConfiguration, ProviderConfiguration, ReplayGainType +from sublime.config import AppConfiguration, ProviderConfiguration @pytest.fixture @@ -13,6 +14,11 @@ def config_filename(tmp_path: Path): yield tmp_path.joinpath("config.json") +@pytest.fixture +def cwd(): + yield Path(__file__).parent + + def test_config_default_cache_location(): config = AppConfiguration() assert config.cache_location == Path("~/.local/share/sublime-music").expanduser() @@ -66,24 +72,19 @@ def test_json_load_unload(config_filename: Path): assert original_config.provider == loaded_config.provider -def test_config_migrate(config_filename: Path): - config = AppConfiguration( - providers={ - "1": ProviderConfiguration( - id="1", - name="foo", - ground_truth_adapter_type=SubsonicAdapter, - ground_truth_adapter_config=ConfigurationStore(), - ) +def test_config_migrate_v5_to_v6(config_filename: Path, cwd: Path): + shutil.copyfile(str(cwd.joinpath("mock_data/config-v5.json")), str(config_filename)) + app_config = AppConfiguration.load_from_file(config_filename) + app_config.migrate() + + assert app_config.version == 6 + assert app_config.player_config == { + "Local Playback": {"Replay Gain": "track"}, + "Chromecast": { + "Serve Local Files to Chromecasts on the LAN": True, + "LAN Server Port Number": 6969, }, - current_provider_id="1", - filename=config_filename, - ) - config.migrate() - - assert config.version == 5 - - -def test_replay_gain_enum(): - for rg in (ReplayGainType.NO, ReplayGainType.TRACK, ReplayGainType.ALBUM): - assert rg == ReplayGainType.from_string(rg.as_string()) + } + app_config.save() + app_config2 = AppConfiguration.load_from_file(config_filename) + assert app_config == app_config2 diff --git a/tests/mock_data/config-v5.json b/tests/mock_data/config-v5.json new file mode 100644 index 0000000..840205b --- /dev/null +++ b/tests/mock_data/config-v5.json @@ -0,0 +1,17 @@ +{ + "allow_song_downloads": true, + "always_stream": false, + "cache_location": "/home/sumner/.local/share/sublime-music", + "concurrent_download_limit": 5, + "current_provider_id": null, + "download_on_stream": true, + "filename": "/home/sumner/.config/sublime-music/config.json", + "offline_mode": false, + "port_number": 6969, + "prefetch_amount": 3, + "providers": {}, + "replay_gain": 1, + "serve_over_lan": true, + "song_play_notification": true, + "version": 5 +} diff --git a/tests/player_tests/chromecast_tests.py b/tests/player_tests/chromecast_tests.py new file mode 100644 index 0000000..f792f6e --- /dev/null +++ b/tests/player_tests/chromecast_tests.py @@ -0,0 +1,14 @@ +from sublime.players.chromecast import ChromecastPlayer + + +def test_init(): + empty_fn = lambda *a, **k: None + ChromecastPlayer( + 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 new file mode 100644 index 0000000..f2faa93 --- /dev/null +++ b/tests/player_tests/mpv_tests.py @@ -0,0 +1,6 @@ +from sublime.players.mpv import MPVPlayer + + +def test_init(): + empty_fn = lambda *a, **k: None + MPVPlayer(empty_fn, empty_fn, empty_fn, {"Replay Gain": "no"})