Continuing the player refactor
This commit is contained in:
2
Pipfile
2
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"
|
||||
|
42
Pipfile.lock
generated
42
Pipfile.lock
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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 {}
|
||||
|
||||
|
@@ -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.
|
||||
"""
|
||||
|
@@ -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)),
|
||||
)
|
||||
|
||||
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
|
||||
|
@@ -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
|
||||
]
|
||||
|
||||
|
@@ -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")
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
17
tests/mock_data/config-v5.json
Normal file
17
tests/mock_data/config-v5.json
Normal 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
|
||||
}
|
14
tests/player_tests/chromecast_tests.py
Normal file
14
tests/player_tests/chromecast_tests.py
Normal 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,
|
||||
},
|
||||
)
|
6
tests/player_tests/mpv_tests.py
Normal file
6
tests/player_tests/mpv_tests.py
Normal 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"})
|
Reference in New Issue
Block a user