1919
2020import random
2121import threading
22+ import logging
2223from enum import IntEnum
2324from pathlib import Path
2425from typing import List , Union , Any
3536from . import utils
3637from . import discord_rpc
3738
39+ logger = logging .getLogger (__name__ )
3840
3941class 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
0 commit comments