Continuing the player refactor

This commit is contained in:
Sumner Evans
2020-06-12 13:31:19 -06:00
parent 599ab0b9e3
commit f053a4ddb9
15 changed files with 408 additions and 183 deletions

View File

@@ -26,7 +26,7 @@ sphinx-rtd-theme = "*"
termcolor = "*" termcolor = "*"
[packages] [packages]
sublime-music = {editable = true,extras = ["chromecast", "keyring", "server"],path = "."} sublime-music = {editable = true, extras = ["chromecast", "keyring", "server"], path = "."}
[requires] [requires]
python_version = "3.8" python_version = "3.8"

42
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "818f55683bf6393f7a95869ffb5ba553f2e47f5ffd079dcb49accf3a4769d91f" "sha256": "f018bc2d21d6dc296af872daca484440360496dc7fd3746880da2fe2996ed0ce"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -263,22 +263,6 @@
], ],
"version": "==0.4.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": { "requests": {
"hashes": [ "hashes": [
"sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
@@ -313,7 +297,8 @@
"editable": true, "editable": true,
"extras": [ "extras": [
"chromecast", "chromecast",
"keyring" "keyring",
"server"
], ],
"path": "." "path": "."
}, },
@@ -345,7 +330,8 @@
"editable": true, "editable": true,
"extras": [ "extras": [
"chromecast", "chromecast",
"keyring" "keyring",
"server"
], ],
"path": "." "path": "."
} }
@@ -458,11 +444,11 @@
}, },
"flake8": { "flake8": {
"hashes": [ "hashes": [
"sha256:c69ac1668e434d37a2d2880b3ca9aafd54b3a10a3ac1ab101d22f29e29cf8634", "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c",
"sha256:ccaa799ef9893cebe69fdfefed76865aeaefbb94cb8545617b2298786a4de9a5" "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"
], ],
"index": "pypi", "index": "pypi",
"version": "==3.8.2" "version": "==3.8.3"
}, },
"flake8-annotations": { "flake8-annotations": {
"hashes": [ "hashes": [
@@ -815,11 +801,11 @@
}, },
"sphinx": { "sphinx": {
"hashes": [ "hashes": [
"sha256:779a519adbd3a70fc7c468af08c5e74829868b0a5b34587b33340e010291856c", "sha256:1c445320a3310baa5ccb8d957267ef4a0fc930dc1234db5098b3d7af14fbb242",
"sha256:ea64df287958ee5aac46be7ac2b7277305b0381d213728c3a49d8bb9b8415807" "sha256:7d3d5087e39ab5a031b75588e9859f011de70e213cd0080ccbc28079fb0786d1"
], ],
"index": "pypi", "index": "pypi",
"version": "==3.0.4" "version": "==3.1.0"
}, },
"sphinx-rtd-theme": { "sphinx-rtd-theme": {
"hashes": [ "hashes": [
@@ -935,10 +921,10 @@
}, },
"wcwidth": { "wcwidth": {
"hashes": [ "hashes": [
"sha256:980fbf4f3c196c0f329cdcd1e84c554d6a211f18e252e525a0cf4223154a41d6", "sha256:79375666b9954d4a1a10739315816324c3e73110af9d0e102d906fdb0aec009f",
"sha256:edbc2b718b4db6cdf393eefe3a420183947d6aa312505ce6754516f458ff8830" "sha256:8c6b5b6ee1360b842645f336d9e5d68c55817c26d3050f46b235ef2bc650e48f"
], ],
"version": "==0.2.3" "version": "==0.2.4"
} }
} }
} }

View File

@@ -42,7 +42,7 @@ from .adapters import (
from .adapters.api_objects import Playlist, PlayQueue, Song from .adapters.api_objects import Playlist, PlayQueue, Song
from .config import AppConfiguration, ProviderConfiguration from .config import AppConfiguration, ProviderConfiguration
from .dbus import dbus_propagate, DBusManager 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.configure_provider import ConfigureProviderDialog
from .ui.main import MainWindow from .ui.main import MainWindow
from .ui.state import RepeatType, UIState from .ui.state import RepeatType, UIState
@@ -60,7 +60,7 @@ class SublimeMusicApp(Gtk.Application):
self.connect("shutdown", self.on_app_shutdown) self.connect("shutdown", self.on_app_shutdown)
player: Optional[Player] = None player_manager: Optional[PlayerManager] = None
exiting: bool = False exiting: bool = False
def do_startup(self): def do_startup(self):
@@ -188,7 +188,7 @@ class SublimeMusicApp(Gtk.Application):
self.loading_state = False self.loading_state = False
self.should_scrobble_song = False self.should_scrobble_song = False
def time_observer(value: Optional[float]): def on_timepos_change(value: Optional[float]):
if ( if (
self.loading_state self.loading_state
or not self.window or not self.window
@@ -234,19 +234,19 @@ class SublimeMusicApp(Gtk.Application):
GLib.idle_add(self.on_next_track) GLib.idle_add(self.on_next_track)
def on_player_event(event: PlayerEvent): 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 assert event.playing is not None
self.app_config.state.playing = event.playing self.app_config.state.playing = event.playing
if self.dbus_manager: if self.dbus_manager:
self.dbus_manager.property_diff() self.dbus_manager.property_diff()
self.update_window() self.update_window()
elif event.type == PlayerEvent.Type.VOLUME_CHANGE: elif event.type == PlayerEvent.EventType.VOLUME_CHANGE:
assert event.volume is not None assert event.volume is not None
self.app_config.state.volume = event.volume self.app_config.state.volume = event.volume
if self.dbus_manager: if self.dbus_manager:
self.dbus_manager.property_diff() self.dbus_manager.property_diff()
self.update_window() self.update_window()
elif event.type == PlayerEvent.Type.STREAM_CACHE_PROGRESS_CHANGE: elif event.type == PlayerEvent.EventType.STREAM_CACHE_PROGRESS_CHANGE:
if ( if (
self.loading_state self.loading_state
or not self.window or not self.window
@@ -264,13 +264,16 @@ class SublimeMusicApp(Gtk.Application):
self.app_config.state.song_stream_cache_progress, self.app_config.state.song_stream_cache_progress,
) )
self.mpv_player = MPVPlayer( def player_device_change_callback(event: PlayerDeviceEvent):
time_observer, on_track_end, on_player_event, self.app_config, 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": if self.app_config.state.current_device != "this device":
# TODO (#120) attempt to connect to the previously connected device # TODO (#120) attempt to connect to the previously connected device

View File

@@ -4,7 +4,7 @@ import pickle
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from pathlib import Path 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 import dataclasses_json
from dataclasses_json import config, DataClassJsonMixin 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 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 @dataclass
class ProviderConfiguration: class ProviderConfiguration:
id: str id: str
@@ -128,19 +109,23 @@ class AppConfiguration(DataClassJsonMixin):
current_provider_id: Optional[str] = None current_provider_id: Optional[str] = None
_loaded_provider_id: Optional[str] = field(default=None, init=False) _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 # Global Settings
song_play_notification: bool = True song_play_notification: bool = True
offline_mode: bool = False offline_mode: bool = False
serve_over_lan: bool = True
port_number: int = 8282
replay_gain: ReplayGainType = ReplayGainType.NO
allow_song_downloads: bool = True allow_song_downloads: bool = True
download_on_stream: bool = True # also download when streaming a song download_on_stream: bool = True # also download when streaming a song
prefetch_amount: int = 3 prefetch_amount: int = 3
concurrent_download_limit: int = 5 concurrent_download_limit: int = 5
# Deprecated # Deprecated. These have also been renamed to avoid using them elsewhere in the app.
always_stream: bool = False # always stream instead of downloading songs _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 @staticmethod
def load_from_file(filename: Path) -> "AppConfiguration": def load_from_file(filename: Path) -> "AppConfiguration":
@@ -170,7 +155,18 @@ class AppConfiguration(DataClassJsonMixin):
for _, provider in self.providers.items(): for _, provider in self.providers.items():
provider.migrate() 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() self.state.migrate()
@property @property

View File

@@ -11,7 +11,7 @@ from gi.repository import Gio, GLib
from sublime.adapters import AdapterManager, CacheMissError from sublime.adapters import AdapterManager, CacheMissError
from sublime.config import AppConfiguration from sublime.config import AppConfiguration
from sublime.players import Player from sublime.players import PlayerManager
from sublime.ui.state import RepeatType from sublime.ui.state import RepeatType
@@ -53,9 +53,11 @@ class DBusManager:
on_set_property: Callable[ on_set_property: Callable[
[Gio.DBusConnection, str, str, str, str, GLib.Variant], None [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.do_on_method_call = do_on_method_call
self.on_set_property = on_set_property self.on_set_property = on_set_property
self.connection = connection self.connection = connection
@@ -187,7 +189,7 @@ class DBusManager:
return DBusManager._escape_re.sub(replace, id) return DBusManager._escape_re.sub(replace, id)
def property_dict(self) -> Dict[str, Any]: 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: if config is None or player is None:
return {} return {}

View File

@@ -1,6 +1,7 @@
import abc import abc
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
@@ -11,14 +12,46 @@ from typing import (
Iterator, Iterator,
List, List,
Optional, Optional,
Set,
Tuple, Tuple,
Type, Type,
Union, Union,
) )
from sublime.adapters.api_objects import Song
@dataclass @dataclass
class PlayerEvent: 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): class EventType(Enum):
PLAY_STATE_CHANGE = 0 PLAY_STATE_CHANGE = 0
VOLUME_CHANGE = 1 VOLUME_CHANGE = 1
@@ -30,6 +63,7 @@ class PlayerEvent:
playing: Optional[bool] = None playing: Optional[bool] = None
volume: Optional[float] = None volume: Optional[float] = None
stream_cache_duration: Optional[float] = None stream_cache_duration: Optional[float] = None
device_id: Optional[str] = None
class Player(abc.ABC): 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. :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 @property
def can_start_playing_with_no_latency(self) -> bool: def can_start_playing_with_no_latency(self) -> bool:
""" """
@@ -61,11 +102,24 @@ class Player(abc.ABC):
""" """
@abc.abstractmethod @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 :param config: A dictionary of configuration key -> configuration value
""" """
def reset(self):
"""
Reset the player.
"""
@abc.abstractmethod @abc.abstractmethod
def shutdown(self): def shutdown(self):
""" """
@@ -86,84 +140,62 @@ class Player(abc.ABC):
""" """
@property @property
@abc.abstractmethod
def song_loaded(self) -> bool: def song_loaded(self) -> bool:
""" """
:returns: whether or not the player currently has a song loaded. :returns: whether or not the player currently has a song loaded.
""" """
return False
@property
@abc.abstractmethod @abc.abstractmethod
def volume(self) -> float: def get_volume(self) -> float:
""" """
:returns: the current volume on a scale of [0, 100] :returns: the current volume on a scale of [0, 100]
""" """
@volume.setter
@abc.abstractmethod @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 @abc.abstractmethod
def is_muted(self) -> bool: def set_muted(self, muted: bool):
return self._get_is_muted() """
:param muted: set the player's "muted" property to the given value.
"""
@is_muted.setter @abc.abstractmethod
def is_muted(self, value: bool): def play_media(self, uri: str, progress: timedelta, song: Song):
self._set_is_muted(value) """
:param uri: the URI to play. The URI is guaranteed to be one of the schemes in
def reset(self): the :class:`supported_schemes` set for this adapter.
raise NotImplementedError("reset must be implemented by implementor of Player") :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
def play_media(self, file_or_url: str, progress: timedelta, song: Song): player.
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 pause(self): def pause(self):
raise NotImplementedError("pause must be implemented by implementor of Player") """
Pause the player.
"""
@abc.abstractmethod
def toggle_play(self): 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): def seek(self, position: timedelta):
raise NotImplementedError("seek must be implemented by implementor of Player") """
:param position: seek to the given position in the song.
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"
)

View File

@@ -1,6 +1,10 @@
import base64
import io import io
import mimetypes import mimetypes
import multiprocessing import multiprocessing
import os
import socket
from datetime import timedelta
from typing import ( from typing import (
Any, Any,
Callable, Callable,
@@ -10,13 +14,16 @@ from typing import (
Iterator, Iterator,
List, List,
Optional, Optional,
Set,
Tuple, Tuple,
Type, Type,
) )
from urllib.parse import urlparse
from sublime.adapters import AdapterManager from sublime.adapters import AdapterManager
from sublime.adapters.api_objects import Song
from .base import Player from .base import Player, PlayerEvent
try: try:
import pychromecast import pychromecast
@@ -40,30 +47,49 @@ class ChromecastPlayer(Player):
name = "Chromecast" name = "Chromecast"
can_start_playing_with_no_latency = False can_start_playing_with_no_latency = False
@property
def enabled(self) -> bool:
return chromecast_imported
@staticmethod @staticmethod
def get_configuration_options() -> Dict[str, Union[Type, Tuple[str, ...]]]: def get_configuration_options() -> Dict[str, Union[Type, Tuple[str, ...]]]:
if not bottle_imported: if not bottle_imported:
return {} return {}
return {SERVE_FILES_KEY: bool, LAN_PORT_KEY: int} return {SERVE_FILES_KEY: bool, LAN_PORT_KEY: int}
def __init__(self, config: Dict[str, Union[str, int, bool]]): def supported_schemes(self) -> Set[str]:
self.supported_schemes = {"http", "https"} 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 self.server_process = None
if bottle_imported and config.get(SERVE_FILES_KEY): self.config = config
self.supported_schemes.add("file") if bottle_imported and self.config.get(SERVE_FILES_KEY):
self.server_process = multiprocessing.Process( self.server_process = multiprocessing.Process(
target=self._run_server_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): def shutdown(self):
if self._current_chromecast:
self._current_chromecast.disconnect()
if self.server_process: if self.server_process:
self.server_process.terminate() self.server_process.terminate()
_serving_song_id = multiprocessing.Value("s") _serving_song_id = multiprocessing.Array("c", 1024) # huge buffer, just in case
_serving_token = multiprocessing.Value("s") _serving_token = multiprocessing.Array("c", 12)
def _run_server_process(self, host: str, port: int): def _run_server_process(self, host: str, port: int):
app = bottle.Bottle() app = bottle.Bottle()
@@ -102,3 +128,84 @@ class ChromecastPlayer(Player):
self._chromecasts = pychromecast.get_chromecasts() self._chromecasts = pychromecast.get_chromecasts()
for chromecast in self._chromecasts: for chromecast in self._chromecasts:
yield (str(chromecast.device.uuid), chromecast.device.friendly_name) 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

View File

@@ -66,7 +66,12 @@ class PlayerManager:
self.on_player_event = on_player_event self.on_player_event = on_player_event
self.players = [ 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 for player_type in PlayerManager.available_player_types
] ]

View File

@@ -1,4 +1,5 @@
import threading import threading
from datetime import timedelta
from typing import ( from typing import (
Callable, Callable,
cast, cast,
@@ -14,7 +15,9 @@ from typing import (
import mpv import mpv
from .base import Player from sublime.adapters.api_objects import Song
from .base import Player, PlayerEvent
REPLAY_GAIN_KEY = "Replay Gain" REPLAY_GAIN_KEY = "Replay Gain"
@@ -24,43 +27,50 @@ class MPVPlayer(Player):
name = "Local Playback" name = "Local Playback"
can_start_playing_with_no_latency = True can_start_playing_with_no_latency = True
supported_schemes = {"http", "https", "file"} supported_schemes = {"http", "https", "file"}
song_loaded = False
_progress_value_lock = threading.Lock()
_progress_value_count = 0
_volume = 100.0
_muted = False
@staticmethod @staticmethod
def get_configuration_options() -> Dict[str, Union[Type, Tuple[str, ...]]]: def get_configuration_options() -> Dict[str, Union[Type, Tuple[str, ...]]]:
return {REPLAY_GAIN_KEY: ("Disabled", "Track", "Album")} 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 = mpv.MPV()
self.mpv.audio_client_name = "sublime-music" self.mpv.audio_client_name = "sublime-music"
self.mpv.replaygain = { self.mpv.replaygain = {
"Disabled": "no", "Disabled": "no",
"Track": "track", "Track": "track",
"Album": "album", "Album": "album",
}.get(cast(str, config.get(REPLAY_GAIN_KEY, "Disabled"))) }.get(cast(str, config.get(REPLAY_GAIN_KEY, "Disabled")), "no")
self.progress_value_lock = threading.Lock()
self.progress_value_count = 0
self._muted = False
self._volume = 100.0
self._can_hotswap_source = True
@self.mpv.property_observer("time-pos") @self.mpv.property_observer("time-pos")
def time_observer(_, value: Optional[float]): def time_observer(_, value: Optional[float]):
self.on_timepos_change(value) on_timepos_change(value)
if value is None and self.progress_value_count > 1: if value is None and self._progress_value_count > 1:
self.on_track_end() on_track_end()
with self.progress_value_lock: with self._progress_value_lock:
self.progress_value_count = 0 self._progress_value_count = 0
if value: if value:
with self.progress_value_lock: with self._progress_value_lock:
self.progress_value_count += 1 self._progress_value_count += 1
@self.mpv.property_observer("demuxer-cache-time") @self.mpv.property_observer("demuxer-cache-time")
def cache_size_observer(_, value: Optional[float]): def cache_size_observer(_, value: Optional[float]):
on_player_event( on_player_event(
PlayerEvent( PlayerEvent(
PlayerEvent.Type.STREAM_CACHE_PROGRESS_CHANGE, PlayerEvent.EventType.STREAM_CACHE_PROGRESS_CHANGE,
stream_cache_duration=value, stream_cache_duration=value,
) )
) )
@@ -68,5 +78,50 @@ class MPVPlayer(Player):
def shutdown(self): def shutdown(self):
pass 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]]: def get_available_player_devices(self) -> Iterator[Tuple[str, str]]:
yield ("this device", "This Device") 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")

View File

@@ -9,7 +9,7 @@ from sublime.adapters import (
DownloadProgress, DownloadProgress,
Result, 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 import albums, artists, browse, player_controls, playlists, util
from sublime.ui.common import IconButton, IconMenuButton, SpinnerImage from sublime.ui.common import IconButton, IconMenuButton, SpinnerImage

View File

@@ -10,7 +10,8 @@ from pychromecast import Chromecast
from sublime.adapters import AdapterManager, Result, SongCacheStatus from sublime.adapters import AdapterManager, Result, SongCacheStatus
from sublime.adapters.api_objects import Song from sublime.adapters.api_objects import Song
from sublime.config import AppConfiguration from sublime.config import AppConfiguration
from sublime.players import ChromecastPlayer # TODO
# from sublime.players import ChromecastPlayer
from sublime.ui import util from sublime.ui import util
from sublime.ui.common import IconButton, IconToggleButton, SpinnerImage from sublime.ui.common import IconButton, IconToggleButton, SpinnerImage
from sublime.ui.state import RepeatType from sublime.ui.state import RepeatType
@@ -44,7 +45,7 @@ class PlayerControls(Gtk.ActionBar):
current_device = None current_device = None
current_playing_index: Optional[int] = None current_playing_index: Optional[int] = None
current_play_queue: Tuple[str, ...] = () current_play_queue: Tuple[str, ...] = ()
chromecasts: List[ChromecastPlayer] = [] # chromecasts: List[ChromecastPlayer] = []
cover_art_update_order_token = 0 cover_art_update_order_token = 0
play_queue_update_order_token = 0 play_queue_update_order_token = 0
devices_requested = False devices_requested = False

View File

@@ -1,3 +1,4 @@
import shutil
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -5,7 +6,7 @@ import pytest
from sublime.adapters import ConfigurationStore from sublime.adapters import ConfigurationStore
from sublime.adapters.filesystem import FilesystemAdapter from sublime.adapters.filesystem import FilesystemAdapter
from sublime.adapters.subsonic import SubsonicAdapter from sublime.adapters.subsonic import SubsonicAdapter
from sublime.config import AppConfiguration, ProviderConfiguration, ReplayGainType from sublime.config import AppConfiguration, ProviderConfiguration
@pytest.fixture @pytest.fixture
@@ -13,6 +14,11 @@ def config_filename(tmp_path: Path):
yield tmp_path.joinpath("config.json") yield tmp_path.joinpath("config.json")
@pytest.fixture
def cwd():
yield Path(__file__).parent
def test_config_default_cache_location(): def test_config_default_cache_location():
config = AppConfiguration() config = AppConfiguration()
assert config.cache_location == Path("~/.local/share/sublime-music").expanduser() 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 assert original_config.provider == loaded_config.provider
def test_config_migrate(config_filename: Path): def test_config_migrate_v5_to_v6(config_filename: Path, cwd: Path):
config = AppConfiguration( shutil.copyfile(str(cwd.joinpath("mock_data/config-v5.json")), str(config_filename))
providers={ app_config = AppConfiguration.load_from_file(config_filename)
"1": ProviderConfiguration( app_config.migrate()
id="1",
name="foo", assert app_config.version == 6
ground_truth_adapter_type=SubsonicAdapter, assert app_config.player_config == {
ground_truth_adapter_config=ConfigurationStore(), "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, app_config.save()
) app_config2 = AppConfiguration.load_from_file(config_filename)
config.migrate() assert app_config == app_config2
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())

View File

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

View File

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

View File

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