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 = "*"
[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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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