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
|
||||
|
||||
def on_track_end():
|
||||
at_end = (
|
||||
self.app_config.state.current_song_index
|
||||
== len(self.app_config.state.play_queue) - 1
|
||||
)
|
||||
at_end = self.app_config.state.next_song_index is None
|
||||
no_repeat = self.app_config.state.repeat_type == RepeatType.NO_REPEAT
|
||||
if at_end and no_repeat:
|
||||
self.app_config.state.playing = False
|
||||
@@ -672,20 +669,11 @@ class SublimeMusicApp(Gtk.Application):
|
||||
if self.app_config.state.current_song is None:
|
||||
# This may happen due to DBUS, ignore.
|
||||
return
|
||||
# Handle song repeating
|
||||
if self.app_config.state.repeat_type == RepeatType.REPEAT_SONG:
|
||||
song_index_to_play = self.app_config.state.current_song_index
|
||||
# Wrap around the play queue if at the end.
|
||||
elif (
|
||||
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
|
||||
|
||||
song_index_to_play = self.app_config.state.next_song_index
|
||||
if song_index_to_play is None:
|
||||
# We may end up here due to D-Bus.
|
||||
return
|
||||
|
||||
self.app_config.state.current_song_index = song_index_to_play
|
||||
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.
|
||||
# Otherwise, go to the previous song.
|
||||
no_repeat = self.app_config.state.repeat_type == RepeatType.NO_REPEAT
|
||||
|
||||
if self.app_config.state.repeat_type == RepeatType.REPEAT_SONG:
|
||||
song_index_to_play = self.app_config.state.current_song_index
|
||||
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.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``
|
||||
# in the callback.
|
||||
@dbus_propagate(self)
|
||||
@@ -1185,6 +1185,16 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.app_config.state.playing = True
|
||||
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.
|
||||
if self.app_config.song_play_notification:
|
||||
try:
|
||||
@@ -1273,6 +1283,22 @@ class SublimeMusicApp(Gtk.Application):
|
||||
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
|
||||
self.update_window()
|
||||
|
||||
|
@@ -94,6 +94,13 @@ class Player(abc.ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def gapless_playback(self) -> bool:
|
||||
"""
|
||||
:returns: whether the player supports and is using gapless playback
|
||||
"""
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
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.
|
||||
"""
|
||||
|
||||
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, ...]]]],
|
||||
):
|
||||
self.current_song: Optional[Song] = None
|
||||
self.next_song_uri: Optional[str] = None
|
||||
self.on_timepos_change = on_timepos_change
|
||||
self.on_track_end = on_track_end
|
||||
self.config = config
|
||||
self.players: Dict[Type, Any] = {}
|
||||
self.device_id_type_map: Dict[str, Type] = {}
|
||||
self._current_device_id: Optional[str] = None
|
||||
self._track_ending: bool = False
|
||||
|
||||
def player_event_wrapper(pe: PlayerEvent):
|
||||
if pe.device_id == self._current_device_id:
|
||||
@@ -58,7 +60,7 @@ class PlayerManager:
|
||||
self.players = {
|
||||
player_type: player_type(
|
||||
self.on_timepos_change,
|
||||
self.on_track_end,
|
||||
self._on_track_end,
|
||||
self.on_player_event,
|
||||
self.player_device_change_callback,
|
||||
self.config.get(player_type.name),
|
||||
@@ -91,6 +93,10 @@ class PlayerManager:
|
||||
if current_player_type := self._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
|
||||
def supported_schemes(self) -> Set[str]:
|
||||
if cp := self._get_current_player():
|
||||
@@ -155,9 +161,34 @@ class PlayerManager:
|
||||
current_player.set_muted(muted)
|
||||
|
||||
def play_media(self, uri: str, progress: timedelta, song: Song):
|
||||
self.current_song = song
|
||||
if current_player := self._get_current_player():
|
||||
current_player.play_media(uri, progress, song)
|
||||
current_player = self._get_current_player()
|
||||
if not current_player:
|
||||
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):
|
||||
if current_player := self._get_current_player():
|
||||
@@ -173,3 +204,10 @@ class PlayerManager:
|
||||
def seek(self, position: timedelta):
|
||||
if current_player := self._get_current_player():
|
||||
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
|
||||
|
||||
REPLAY_GAIN_KEY = "Replay Gain"
|
||||
GAPLESS_PLAYBACK_KEY = "Gapless Playback"
|
||||
|
||||
|
||||
class MPVPlayer(Player):
|
||||
@@ -27,7 +28,10 @@ class MPVPlayer(Player):
|
||||
|
||||
@staticmethod
|
||||
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__(
|
||||
self,
|
||||
@@ -100,6 +104,10 @@ class MPVPlayer(Player):
|
||||
def playing(self) -> bool:
|
||||
return not self.mpv.pause
|
||||
|
||||
@property
|
||||
def gapless_playback(self) -> bool:
|
||||
return self.config.get(GAPLESS_PLAYBACK_KEY) == "Enabled"
|
||||
|
||||
def get_volume(self) -> float:
|
||||
return self._volume
|
||||
|
||||
@@ -119,6 +127,9 @@ class MPVPlayer(Player):
|
||||
with self._progress_value_lock:
|
||||
self._progress_value_count = 0
|
||||
|
||||
# Clears everything except the currently-playing song
|
||||
self.mpv.command("playlist-clear")
|
||||
|
||||
options = {
|
||||
"force-seekable": "yes",
|
||||
"start": str(progress.total_seconds()),
|
||||
@@ -137,3 +148,12 @@ class MPVPlayer(Player):
|
||||
|
||||
def seek(self, position: timedelta):
|
||||
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
|
||||
|
||||
@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
|
||||
def volume(self) -> float:
|
||||
return self._volume.get(self.current_device, 100.0)
|
||||
|
Reference in New Issue
Block a user