Merge branch 'gapless-playback-mpv' into 'master'
Added basic Gapless Playback support for mpv Closes #73 See merge request sublime-music/sublime-music!72
This commit is contained in:
@@ -222,10 +222,7 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
self.should_scrobble_song = False
|
self.should_scrobble_song = False
|
||||||
|
|
||||||
def on_track_end():
|
def on_track_end():
|
||||||
at_end = (
|
at_end = self.app_config.state.next_song_index is None
|
||||||
self.app_config.state.current_song_index
|
|
||||||
== len(self.app_config.state.play_queue) - 1
|
|
||||||
)
|
|
||||||
no_repeat = self.app_config.state.repeat_type == RepeatType.NO_REPEAT
|
no_repeat = self.app_config.state.repeat_type == RepeatType.NO_REPEAT
|
||||||
if at_end and no_repeat:
|
if at_end and no_repeat:
|
||||||
self.app_config.state.playing = False
|
self.app_config.state.playing = False
|
||||||
@@ -672,20 +669,11 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
if self.app_config.state.current_song is None:
|
if self.app_config.state.current_song is None:
|
||||||
# This may happen due to DBUS, ignore.
|
# This may happen due to DBUS, ignore.
|
||||||
return
|
return
|
||||||
# Handle song repeating
|
|
||||||
if self.app_config.state.repeat_type == RepeatType.REPEAT_SONG:
|
song_index_to_play = self.app_config.state.next_song_index
|
||||||
song_index_to_play = self.app_config.state.current_song_index
|
if song_index_to_play is None:
|
||||||
# Wrap around the play queue if at the end.
|
# We may end up here due to D-Bus.
|
||||||
elif (
|
return
|
||||||
self.app_config.state.current_song_index
|
|
||||||
== len(self.app_config.state.play_queue) - 1
|
|
||||||
):
|
|
||||||
# This may happen due to D-Bus.
|
|
||||||
if self.app_config.state.repeat_type == RepeatType.NO_REPEAT:
|
|
||||||
return
|
|
||||||
song_index_to_play = 0
|
|
||||||
else:
|
|
||||||
song_index_to_play = self.app_config.state.current_song_index + 1
|
|
||||||
|
|
||||||
self.app_config.state.current_song_index = song_index_to_play
|
self.app_config.state.current_song_index = song_index_to_play
|
||||||
self.app_config.state.song_progress = timedelta(0)
|
self.app_config.state.song_progress = timedelta(0)
|
||||||
@@ -701,6 +689,7 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
# Go back to the beginning of the song if we are past 5 seconds.
|
# Go back to the beginning of the song if we are past 5 seconds.
|
||||||
# Otherwise, go to the previous song.
|
# Otherwise, go to the previous song.
|
||||||
no_repeat = self.app_config.state.repeat_type == RepeatType.NO_REPEAT
|
no_repeat = self.app_config.state.repeat_type == RepeatType.NO_REPEAT
|
||||||
|
|
||||||
if self.app_config.state.repeat_type == RepeatType.REPEAT_SONG:
|
if self.app_config.state.repeat_type == RepeatType.REPEAT_SONG:
|
||||||
song_index_to_play = self.app_config.state.current_song_index
|
song_index_to_play = self.app_config.state.current_song_index
|
||||||
elif self.app_config.state.song_progress.total_seconds() < 5:
|
elif self.app_config.state.song_progress.total_seconds() < 5:
|
||||||
@@ -1138,6 +1127,17 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
self.app_config.state.song_progress = timedelta(0)
|
self.app_config.state.song_progress = timedelta(0)
|
||||||
self.should_scrobble_song = True
|
self.should_scrobble_song = True
|
||||||
|
|
||||||
|
# Tell the player that the next song is available for gapless playback
|
||||||
|
def do_notify_next_song(next_song: Song):
|
||||||
|
try:
|
||||||
|
next_uri = AdapterManager.get_song_file_uri(next_song)
|
||||||
|
if self.player_manager:
|
||||||
|
self.player_manager.next_media_cached(next_uri, next_song)
|
||||||
|
except CacheMissError:
|
||||||
|
logging.debug(
|
||||||
|
"Couldn't find the file for next song for gapless playback"
|
||||||
|
)
|
||||||
|
|
||||||
# Do this the old fashioned way so that we can have access to ``reset``
|
# Do this the old fashioned way so that we can have access to ``reset``
|
||||||
# in the callback.
|
# in the callback.
|
||||||
@dbus_propagate(self)
|
@dbus_propagate(self)
|
||||||
@@ -1185,6 +1185,16 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
self.app_config.state.playing = True
|
self.app_config.state.playing = True
|
||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
|
# Check if the next song is available in the cache
|
||||||
|
if (next_song_index := self.app_config.state.next_song_index) is not None:
|
||||||
|
next_song_details_future = AdapterManager.get_song_details(
|
||||||
|
self.app_config.state.play_queue[next_song_index]
|
||||||
|
)
|
||||||
|
|
||||||
|
next_song_details_future.add_done_callback(
|
||||||
|
lambda f: GLib.idle_add(do_notify_next_song, f.result()),
|
||||||
|
)
|
||||||
|
|
||||||
# Show a song play notification.
|
# Show a song play notification.
|
||||||
if self.app_config.song_play_notification:
|
if self.app_config.song_play_notification:
|
||||||
try:
|
try:
|
||||||
@@ -1273,6 +1283,22 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
song,
|
song,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Handle case where a next-song was previously not cached
|
||||||
|
# but is now available for the player to use
|
||||||
|
if self.app_config.state.playing:
|
||||||
|
next_song_index = self.app_config.state.next_song_index
|
||||||
|
if (
|
||||||
|
next_song_index is not None
|
||||||
|
and self.app_config.state.play_queue[next_song_index] == song_id
|
||||||
|
):
|
||||||
|
next_song_details_future = AdapterManager.get_song_details(
|
||||||
|
song_id
|
||||||
|
)
|
||||||
|
|
||||||
|
next_song_details_future.add_done_callback(
|
||||||
|
lambda f: GLib.idle_add(do_notify_next_song, f.result()),
|
||||||
|
)
|
||||||
|
|
||||||
# Always update the window
|
# Always update the window
|
||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
|
@@ -94,6 +94,13 @@ class Player(abc.ABC):
|
|||||||
"""
|
"""
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gapless_playback(self) -> bool:
|
||||||
|
"""
|
||||||
|
:returns: whether the player supports and is using gapless playback
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def get_configuration_options() -> Dict[str, Union[Type, Tuple[str, ...]]]:
|
def get_configuration_options() -> Dict[str, Union[Type, Tuple[str, ...]]]:
|
||||||
@@ -213,3 +220,10 @@ class Player(abc.ABC):
|
|||||||
"""
|
"""
|
||||||
:param position: seek to the given position in the song.
|
:param position: seek to the given position in the song.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def next_media_cached(self, uri: str, song: Song):
|
||||||
|
"""
|
||||||
|
:param uri: the URI to prepare to play. The URI is guaranteed to be one of
|
||||||
|
the schemes in the :class:`supported_schemes` set for this adapter.
|
||||||
|
:param song: the actual song.
|
||||||
|
"""
|
||||||
|
@@ -36,12 +36,14 @@ class PlayerManager:
|
|||||||
config: Dict[str, Dict[str, Union[Type, Tuple[str, ...]]]],
|
config: Dict[str, Dict[str, Union[Type, Tuple[str, ...]]]],
|
||||||
):
|
):
|
||||||
self.current_song: Optional[Song] = None
|
self.current_song: Optional[Song] = None
|
||||||
|
self.next_song_uri: Optional[str] = None
|
||||||
self.on_timepos_change = on_timepos_change
|
self.on_timepos_change = on_timepos_change
|
||||||
self.on_track_end = on_track_end
|
self.on_track_end = on_track_end
|
||||||
self.config = config
|
self.config = config
|
||||||
self.players: Dict[Type, Any] = {}
|
self.players: Dict[Type, Any] = {}
|
||||||
self.device_id_type_map: Dict[str, Type] = {}
|
self.device_id_type_map: Dict[str, Type] = {}
|
||||||
self._current_device_id: Optional[str] = None
|
self._current_device_id: Optional[str] = None
|
||||||
|
self._track_ending: bool = False
|
||||||
|
|
||||||
def player_event_wrapper(pe: PlayerEvent):
|
def player_event_wrapper(pe: PlayerEvent):
|
||||||
if pe.device_id == self._current_device_id:
|
if pe.device_id == self._current_device_id:
|
||||||
@@ -58,7 +60,7 @@ class PlayerManager:
|
|||||||
self.players = {
|
self.players = {
|
||||||
player_type: player_type(
|
player_type: player_type(
|
||||||
self.on_timepos_change,
|
self.on_timepos_change,
|
||||||
self.on_track_end,
|
self._on_track_end,
|
||||||
self.on_player_event,
|
self.on_player_event,
|
||||||
self.player_device_change_callback,
|
self.player_device_change_callback,
|
||||||
self.config.get(player_type.name),
|
self.config.get(player_type.name),
|
||||||
@@ -91,6 +93,10 @@ class PlayerManager:
|
|||||||
if current_player_type := self._get_current_player_type():
|
if current_player_type := self._get_current_player_type():
|
||||||
return self.players.get(current_player_type)
|
return self.players.get(current_player_type)
|
||||||
|
|
||||||
|
def _on_track_end(self):
|
||||||
|
self._track_ending = True
|
||||||
|
self.on_track_end()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supported_schemes(self) -> Set[str]:
|
def supported_schemes(self) -> Set[str]:
|
||||||
if cp := self._get_current_player():
|
if cp := self._get_current_player():
|
||||||
@@ -155,9 +161,34 @@ class PlayerManager:
|
|||||||
current_player.set_muted(muted)
|
current_player.set_muted(muted)
|
||||||
|
|
||||||
def play_media(self, uri: str, progress: timedelta, song: Song):
|
def play_media(self, uri: str, progress: timedelta, song: Song):
|
||||||
self.current_song = song
|
current_player = self._get_current_player()
|
||||||
if current_player := self._get_current_player():
|
if not current_player:
|
||||||
current_player.play_media(uri, progress, song)
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
current_player.gapless_playback
|
||||||
|
and self.next_song_uri
|
||||||
|
and uri == self.next_song_uri
|
||||||
|
and progress == timedelta(0)
|
||||||
|
and self._track_ending
|
||||||
|
):
|
||||||
|
# In this case the player already knows about the next
|
||||||
|
# song and will automatically play it when the current
|
||||||
|
# song is complete.
|
||||||
|
self.current_song = song
|
||||||
|
self.next_song_uri = None
|
||||||
|
self._track_ending = False
|
||||||
|
current_player.song_loaded = True
|
||||||
|
return
|
||||||
|
|
||||||
|
# If we are changing the current song then the next song
|
||||||
|
# should also be invalidated.
|
||||||
|
if self.current_song != song:
|
||||||
|
self.current_song = song
|
||||||
|
self.next_song_uri = None
|
||||||
|
|
||||||
|
self._track_ending = False
|
||||||
|
current_player.play_media(uri, progress, song)
|
||||||
|
|
||||||
def pause(self):
|
def pause(self):
|
||||||
if current_player := self._get_current_player():
|
if current_player := self._get_current_player():
|
||||||
@@ -173,3 +204,10 @@ class PlayerManager:
|
|||||||
def seek(self, position: timedelta):
|
def seek(self, position: timedelta):
|
||||||
if current_player := self._get_current_player():
|
if current_player := self._get_current_player():
|
||||||
current_player.seek(position)
|
current_player.seek(position)
|
||||||
|
|
||||||
|
def next_media_cached(self, uri: str, song: Song):
|
||||||
|
if current_player := self._get_current_player():
|
||||||
|
if current_player.gapless_playback:
|
||||||
|
self.next_song_uri = uri
|
||||||
|
|
||||||
|
current_player.next_media_cached(uri, song)
|
||||||
|
@@ -8,6 +8,7 @@ from .base import Player, PlayerDeviceEvent, PlayerEvent
|
|||||||
from ..adapters.api_objects import Song
|
from ..adapters.api_objects import Song
|
||||||
|
|
||||||
REPLAY_GAIN_KEY = "Replay Gain"
|
REPLAY_GAIN_KEY = "Replay Gain"
|
||||||
|
GAPLESS_PLAYBACK_KEY = "Gapless Playback"
|
||||||
|
|
||||||
|
|
||||||
class MPVPlayer(Player):
|
class MPVPlayer(Player):
|
||||||
@@ -27,7 +28,10 @@ class MPVPlayer(Player):
|
|||||||
|
|
||||||
@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"),
|
||||||
|
GAPLESS_PLAYBACK_KEY: ("Disabled", "Enabled"),
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -100,6 +104,10 @@ class MPVPlayer(Player):
|
|||||||
def playing(self) -> bool:
|
def playing(self) -> bool:
|
||||||
return not self.mpv.pause
|
return not self.mpv.pause
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gapless_playback(self) -> bool:
|
||||||
|
return self.config.get(GAPLESS_PLAYBACK_KEY) == "Enabled"
|
||||||
|
|
||||||
def get_volume(self) -> float:
|
def get_volume(self) -> float:
|
||||||
return self._volume
|
return self._volume
|
||||||
|
|
||||||
@@ -119,6 +127,9 @@ class MPVPlayer(Player):
|
|||||||
with self._progress_value_lock:
|
with self._progress_value_lock:
|
||||||
self._progress_value_count = 0
|
self._progress_value_count = 0
|
||||||
|
|
||||||
|
# Clears everything except the currently-playing song
|
||||||
|
self.mpv.command("playlist-clear")
|
||||||
|
|
||||||
options = {
|
options = {
|
||||||
"force-seekable": "yes",
|
"force-seekable": "yes",
|
||||||
"start": str(progress.total_seconds()),
|
"start": str(progress.total_seconds()),
|
||||||
@@ -137,3 +148,12 @@ class MPVPlayer(Player):
|
|||||||
|
|
||||||
def seek(self, position: timedelta):
|
def seek(self, position: timedelta):
|
||||||
self.mpv.seek(str(position.total_seconds()), "absolute")
|
self.mpv.seek(str(position.total_seconds()), "absolute")
|
||||||
|
|
||||||
|
def next_media_cached(self, uri: str, song: Song):
|
||||||
|
if not self.gapless_playback:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure the only 2 things in the playlist are the current song
|
||||||
|
# and the next song for gapless playback
|
||||||
|
self.mpv.command("playlist-clear")
|
||||||
|
self.mpv.command("loadfile", uri, "append")
|
||||||
|
@@ -137,6 +137,28 @@ class UIState:
|
|||||||
|
|
||||||
return self._current_song
|
return self._current_song
|
||||||
|
|
||||||
|
@property
|
||||||
|
def next_song_index(self) -> Optional[int]:
|
||||||
|
# If nothing is playing there is no next song
|
||||||
|
if self.current_song_index < 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.repeat_type == RepeatType.REPEAT_SONG:
|
||||||
|
return self.current_song_index
|
||||||
|
|
||||||
|
# If we are at the end of the play queue
|
||||||
|
if self.current_song_index == len(self.play_queue) - 1:
|
||||||
|
|
||||||
|
# If we are repeating the queue, jump back to the beginning
|
||||||
|
if self.repeat_type == RepeatType.REPEAT_QUEUE:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Otherwise, there isn't a next song
|
||||||
|
return None
|
||||||
|
|
||||||
|
# In all other cases, it's the song after the current one
|
||||||
|
return self.current_song_index + 1
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def volume(self) -> float:
|
def volume(self) -> float:
|
||||||
return self._volume.get(self.current_device, 100.0)
|
return self._volume.get(self.current_device, 100.0)
|
||||||
|
Reference in New Issue
Block a user