Skip to content

Commit f252d31

Browse files
authored
Merge pull request #100 from Plamper/gapless
Gapless Playback
2 parents 95edec2 + c5fe216 commit f252d31

File tree

3 files changed

+165
-38
lines changed

3 files changed

+165
-38
lines changed

data/ui/preferences.blp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Adw.PreferencesDialog _preference_window {
3030
};
3131

3232
title: _("Preferred Audio Sink");
33+
subtitle: _("Gapless playback is disabled for Pipewire due to a bug. Use 'Automatic' for gapless playback.");
3334
}
3435
Adw.SwitchRow _normalize_row {
3536
title: _("Normalize volume");

src/lib/player_object.py

Lines changed: 162 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import random
2121
import threading
22+
import logging
2223
from enum import IntEnum
2324
from pathlib import Path
2425
from typing import List, Union, Any
@@ -35,6 +36,7 @@
3536
from . import utils
3637
from . import discord_rpc
3738

39+
logger = logging.getLogger(__name__)
3840

3941
class RepeatType(IntEnum):
4042
NONE = 0
@@ -84,9 +86,18 @@ def __init__(
8486
self.pipeline = Gst.Pipeline.new("dash-player")
8587

8688
self.playbin = Gst.ElementFactory.make("playbin3", "playbin")
87-
if not self.playbin:
89+
if self.playbin:
90+
self.playbin.connect("about-to-finish", self.play_next_gapless)
91+
self.gapless_enabled = True
92+
else:
8893
print("Could not create playbin3 element, trying playbin...")
8994
self.playbin = Gst.ElementFactory.make("playbin", "playbin")
95+
self.gapless_enabled = False
96+
97+
if preferred_sink == AudioSink.PIPEWIRE:
98+
self.gapless_enabled = False
99+
100+
self.use_about_to_finish = True
90101

91102
self.pipeline.add(self.playbin)
92103

@@ -105,6 +116,7 @@ def __init__(
105116
self._bus.connect("message::eos", self._on_bus_eos)
106117
self._bus.connect("message::error", self._on_bus_error)
107118
self._bus.connect("message::buffering", self._on_buffering_message)
119+
self._bus.connect("message::stream-start", self._on_track_start)
108120

109121
# Initialize state utils
110122
self._shuffle = False
@@ -125,6 +137,11 @@ def __init__(
125137
self.manifest: Any | None = None
126138
self.stream: Any | None = None
127139
self.update_timer: Any | None = None
140+
self.seek_after_sink_reload: int | None = None
141+
self.seeked_to_end = False
142+
143+
# next track variables for gapless
144+
self.next_track: Any | None = None
128145

129146
@GObject.Property(type=bool, default=False)
130147
def playing(self) -> bool:
@@ -147,7 +164,7 @@ def shuffle(self, _shuffle: bool) -> None:
147164
self._shuffle = _shuffle
148165
self.notify("shuffle")
149166
self._update_shuffle_queue()
150-
self.emit("song-changed")
167+
# self.emit("song-changed")
151168

152169
@GObject.Property(type=int, default=0)
153170
def repeat_type(self) -> RepeatType:
@@ -185,6 +202,11 @@ def _setup_audio_sink(self, sink_type: AudioSink) -> None:
185202
f"queue ! audioconvert ! {normalization} audioresample ! {sink_name}"
186203
)
187204

205+
if sink_type == AudioSink.PIPEWIRE:
206+
self.gapless_enabled = False
207+
else:
208+
self.gapless_enabled = True
209+
188210
try:
189211
audio_bin = Gst.parse_bin_from_description(pipeline_str, True)
190212
if not audio_bin:
@@ -203,6 +225,9 @@ def change_audio_sink(self, sink_type: AudioSink) -> None:
203225
Args:
204226
sink_type (int): The audio sink `AudioSink` enum
205227
"""
228+
self.use_about_to_finish = False
229+
# Play the same track again after reload
230+
self.next_track = self.playing_track
206231
was_playing: bool = self.playing
207232
position: int = self.query_position()
208233
duration: int = self.query_duration()
@@ -212,11 +237,15 @@ def change_audio_sink(self, sink_type: AudioSink) -> None:
212237

213238
if was_playing and duration != 0:
214239
self.pipeline.set_state(Gst.State.PLAYING)
215-
self.seek(position / duration)
240+
self.seek_after_sink_reload = position / duration
241+
self.use_about_to_finish = True
216242

217243
def _on_bus_eos(self, *args) -> None:
218244
"""Handle end of stream."""
219-
GLib.idle_add(self.play_next)
245+
if not self.tracks_to_play or not self.queue:
246+
self.pause()
247+
if not self.gapless_enabled:
248+
GLib.idle_add(self.play_next)
220249

221250
def _on_bus_error(self, bus: Any, message: Any) -> None:
222251
"""Handle pipeline errors."""
@@ -230,6 +259,60 @@ def _on_buffering_message(self, bus: Any, message: Any) -> None:
230259

231260
self.emit("buffering", buffer_per)
232261

262+
def set_track(self, track: Track | None = None):
263+
"""Sets the currently Playing track
264+
265+
Args:
266+
track: If set, the playing track is set to it.
267+
Otherwise self.next_track is used
268+
"""
269+
if not track and not self.next_track:
270+
# This method has already been called in _play_track_url
271+
return
272+
if track:
273+
self.playing_track = track
274+
else:
275+
self.playing_track = self.next_track
276+
self.next_track = None
277+
self.song_album = self.playing_track.album
278+
self.can_go_next = len(self._tracks_to_play) > 0
279+
self.can_go_prev = len(self.played_songs) > 0
280+
self.duration = self.query_duration()
281+
# Should only trigger when track is enqued on start without playback
282+
if not self.duration:
283+
# self.duration is microseconds, but self.playing_track.duration is seconds
284+
self.duration = self.playing_track.duration * 1_000_000_000
285+
self.notify("can-go-prev")
286+
self.notify("can-go-next")
287+
self.emit("song-changed")
288+
289+
def _on_track_start(self, bus: Any, message: Any):
290+
"""This Method is called when a new track starts playing
291+
292+
Args:
293+
bus: required by Gst
294+
message: required by Gst
295+
"""
296+
# apply replaygain first to avoid volume clipping
297+
# (Idk if that will happen but its the only thing that has effect on audio in here)
298+
if self.stream:
299+
self.apply_replaygain_tags()
300+
self.set_track()
301+
302+
if self.discord_rpc_enabled and self.playing_track:
303+
discord_rpc.set_activity(
304+
self.playing_track, 0
305+
)
306+
307+
if self.update_timer:
308+
GLib.source_remove(self.update_timer)
309+
self.update_timer = GLib.timeout_add(1000, self._update_slider_callback)
310+
311+
self.seeked_to_end = False
312+
if self.seek_after_sink_reload:
313+
self.seek(self.seek_after_sink_reload)
314+
self.seek_after_sink_reload = None
315+
233316
def play_this(
234317
self, thing: Union[Mix, Album, Playlist, List[Track], Track], index: int = 0
235318
) -> None:
@@ -257,9 +340,9 @@ def play_this(
257340
if self.shuffle:
258341
self._update_shuffle_queue()
259342

343+
# Will result in play() call later
344+
self.playing = True
260345
self.play_track(track)
261-
self.play()
262-
self.emit("song-changed")
263346

264347
def shuffle_this(
265348
self, thing: Union[Mix, Album, Playlist, List[Track], Track]
@@ -307,14 +390,14 @@ def play(self) -> None:
307390
"""Start playback of the current track."""
308391
self.playing = True
309392
self.pipeline.set_state(Gst.State.PLAYING)
310-
if self.update_timer:
311-
GLib.source_remove(self.update_timer)
312-
self.update_timer = GLib.timeout_add(1000, self._update_slider_callback)
313393

314394
if self.discord_rpc_enabled and self.playing_track:
315395
discord_rpc.set_activity(
316396
self.playing_track, self.query_position() / 1_000_000
317397
)
398+
if self.update_timer:
399+
GLib.source_remove(self.update_timer)
400+
self.update_timer = GLib.timeout_add(1000, self._update_slider_callback)
318401

319402
def pause(self) -> None:
320403
"""Pause playback of the current track."""
@@ -331,16 +414,22 @@ def play_pause(self) -> None:
331414
else:
332415
self.play()
333416

334-
def play_track(self, track: Track) -> None:
335-
"""Play a specific track immediately.
417+
def play_track(self, track: Track, gapless=False) -> None:
418+
"""Play a specific track immediately or enqueue it for gapless playback
336419
337420
Args:
338421
track: The Track object to play
422+
gapless: Whether to enqueue the track for gapless playback
339423
"""
340-
threading.Thread(target=self._play_track_thread, args=(track,)).start()
424+
threading.Thread(target=self._play_track_thread, args=(track, gapless)).start()
341425

342-
def _play_track_thread(self, track: Track) -> None:
343-
"""Thread for loading and playing a track."""
426+
def _play_track_thread(self, track: Track, gapless=False) -> None:
427+
"""Thread for loading and playing a track.
428+
429+
Args:
430+
track: The Track object to play
431+
gapless: Whether to enqueue the track for gapless playback
432+
"""
344433

345434
self.stream = None
346435
self.manifest = None
@@ -350,7 +439,9 @@ def _play_track_thread(self, track: Track) -> None:
350439
self.manifest = self.stream.get_stream_manifest()
351440
urls = self.manifest.get_urls()
352441

353-
self.apply_replaygain_tags()
442+
# When not gapless there is a race condition between get_stream() and on_track_start
443+
if not gapless:
444+
self.apply_replaygain_tags()
354445

355446
if self.stream.manifest_mime_type == ManifestMimeType.MPD:
356447
data = self.stream.get_manifest_data()
@@ -369,7 +460,7 @@ def _play_track_thread(self, track: Track) -> None:
369460
else:
370461
music_url = urls
371462

372-
GLib.idle_add(self._play_track_url, track, music_url)
463+
GLib.idle_add(self._play_track_url, track, music_url, gapless)
373464
except Exception as e:
374465
print(f"Error getting track URL: {e}")
375466

@@ -403,41 +494,69 @@ def apply_replaygain_tags(self):
403494
# toggling the option
404495
self.most_recent_rg_tags = f"tags={tags}"
405496

406-
def _play_track_url(self, track, music_url):
497+
def _play_track_url(self, track, music_url, gapless=False):
407498
"""Set up and play track from URL."""
408-
self.pipeline.set_state(Gst.State.NULL)
499+
if not gapless:
500+
self.use_about_to_finish = False
501+
self.pipeline.set_state(Gst.State.NULL)
502+
self.playbin.set_property("volume", self.playbin.get_property("volume"))
409503
self.playbin.set_property("uri", music_url)
410-
self.playbin.set_property("volume", self.playbin.get_property("volume"))
411-
self.duration = self.query_duration()
412504

413505
print(music_url)
414506

415-
self.playing_track = track
416-
self.song_album = track.album
507+
if gapless:
508+
self.next_track = track
509+
else:
510+
self.set_track(track)
417511

418-
if self.playing:
512+
if not gapless and self.playing:
419513
self.play()
420514

421-
self.can_go_next = len(self._tracks_to_play) > 0
422-
self.can_go_prev = len(self.played_songs) > 0
423-
self.notify("can-go-prev")
424-
self.notify("can-go-next")
515+
if not gapless:
516+
self.use_about_to_finish = True
425517

426-
self.emit("song-changed")
518+
def play_next_gapless(self, playbin: Any):
519+
"""Enqueue the next track for gapless playback.
427520
428-
def play_next(self):
429-
"""Play the next track in the queue or playlist."""
430-
if self._repeat_type == RepeatType.SONG:
521+
Args:
522+
playbin: required by Gst
523+
"""
524+
# playbin is need as arg but we access it later over self
525+
if self.gapless_enabled and self.use_about_to_finish and self.tracks_to_play:
526+
GLib.idle_add(self.play_next, True)
527+
logger.info("Trying gapless playbck")
528+
else:
529+
logger.info("Ignoring about to finish event")
530+
531+
def play_next(self, gapless=False):
532+
"""Play the next track in the queue or playlist.
533+
534+
Args:
535+
gapless: Whether to enqueue the track in gapless mode
536+
"""
537+
538+
# A track is already enqueued from an about-to-finish
539+
if self.next_track:
540+
logger.info("Using already enqueued track from gapless")
541+
track = self.next_track
542+
self.next_track = None
543+
self.play_track(track, gapless=gapless)
544+
return
545+
546+
if self._repeat_type == RepeatType.SONG and not gapless:
431547
self.seek(0)
432548
self.apply_replaygain_tags()
433549
return
550+
if self._repeat_type == RepeatType.SONG:
551+
self.play_track(self.playing_track, gapless=True)
552+
return
434553

435554
if self.playing_track:
436555
self.played_songs.append(self.playing_track)
437556

438557
if self.queue:
439558
track = self.queue.pop(0)
440-
self.play_track(track)
559+
self.play_track(track, gapless=gapless)
441560
return
442561

443562
if not self._tracks_to_play and self._repeat_type == RepeatType.LIST:
@@ -457,7 +576,7 @@ def play_next(self):
457576

458577
if track_list and len(track_list) > 0:
459578
track = track_list.pop(0)
460-
self.play_track(track)
579+
self.play_track(track, gapless=gapless)
461580

462581
def play_previous(self):
463582
"""Play the previous track or restart current track if near beginning."""
@@ -528,11 +647,10 @@ def change_volume(self, value):
528647
def _update_slider_callback(self):
529648
"""Update playback slider and duration."""
530649
self.update_timer = None
650+
if not self.duration:
651+
logger.warn("Duration missing, trying again")
652+
self.duration = self.query_duration()
531653
self.emit("update-slider")
532-
duration = self.query_duration()
533-
if duration != self.duration:
534-
self.duration = duration
535-
self.emit("duration-changed")
536654
return self.playing
537655

538656
def query_duration(self):
@@ -563,6 +681,13 @@ def seek(self, seek_fraction):
563681
seek_fraction (float): Position as a fraction of total duration (0.0 to 1.0)
564682
"""
565683

684+
# If a seek close to the end is performed then skip
685+
# Avoids UI desync and stuck tracks
686+
if not self.seeked_to_end and seek_fraction > 0.98:
687+
self.use_about_to_finish = False
688+
self.seeked_to_end = True
689+
self.play_next()
690+
return
566691
position = int(seek_fraction * self.query_duration())
567692
self.playbin.seek_simple(
568693
Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, position

src/window.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -642,7 +642,8 @@ def update_slider(self, *args):
642642
Called periodically to update the progress bar, song duration, current position
643643
and volume level.
644644
"""
645-
self.duration = self.player_object.query_duration()
645+
# Just copy the duration from player here to avoid ui desync from player object
646+
self.duration = self.player_object.duration
646647
end_value = self.duration / Gst.SECOND
647648

648649
self.volume_button.get_adjustment().set_value(self.player_object.query_volume())

0 commit comments

Comments
 (0)