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:
Sumner Evans
2022-02-14 14:21:14 +00:00
5 changed files with 143 additions and 23 deletions

View File

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

View File

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

View File

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

View File

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

View File

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