v0.5.2: custom start times survive a track change

Playing a track with an iTunes start time (e.g. "Pretty Girls is a
Motherfucker", 0:40) began at 0:00 whenever another track was already
loaded. QMediaPlayer.setSource() synchronously emits two status changes
before returning: a LoadedMedia still reporting the *outgoing* source,
then LoadingMedia for the new one. The stale first event consumed the
one-shot _pending_start_ms armed in Round 22 and seeked the dying
pipeline, so the new track's real LoadedMedia ~5 ms later found nothing
armed. Only the first track after launch worked.

The armed seek now carries the URL it belongs to and is consumed only
when source() matches. tests/test_round32.py models the real Qt event
sequence, which Round 22's plain MagicMock never emitted; the
test_round8/test_round22 stubs now report the new source by the time its
LoadedMedia arrives, as Qt does.
This commit is contained in:
2026-08-14 09:28:50 -04:00
parent d73aa3190d
commit d1371ab703
6 changed files with 227 additions and 7 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.5.1"
__version__ = "0.5.2"
+22 -6
View File
@@ -106,8 +106,11 @@ class LocalSink(PlaybackSink):
super().__init__(parent)
self._volume = 1.0 # logical 0..1; what the volume slider shows
# Custom start time (ms) for the loaded track: applied once the media
# reports itself loaded, which is when it is reliably seekable.
# reports itself loaded, which is when it is reliably seekable. Tagged
# with the URL it was armed for, because setSource() fires a LoadedMedia
# for the *outgoing* media that would otherwise consume it (see load()).
self._pending_start_ms = 0
self._pending_start_url = QUrl()
self._paused_at: float | None = None # monotonic time of last pause
self._shutdown_done = False
@@ -132,7 +135,7 @@ class LocalSink(PlaybackSink):
self._media.errorOccurred.connect(self._on_error)
def load(self, track, autoplay: bool, start_ms: int):
self._pending_start_ms = max(0, start_ms)
start_ms = max(0, start_ms)
url = QUrl.fromLocalFile(track.location)
if self._media.source() == url:
# setSource() no-ops on an unchanged URL, so LoadedMedia never
@@ -140,10 +143,19 @@ class LocalSink(PlaybackSink):
# a clear+reload races the FFmpeg backend, which snaps the seek
# back to 0). The media is already loaded: rewind and seek now.
self._media.stop()
if self._pending_start_ms:
self._media.setPosition(self._pending_start_ms)
self._pending_start_ms = 0
self._pending_start_ms = 0
self._pending_start_url = QUrl()
if start_ms:
self._media.setPosition(start_ms)
else:
# setSource() synchronously emits a *stale* LoadedMedia that still
# reports the outgoing media (the previous track dropping back from
# BufferedMedia) before it starts loading the new one. Tagging the
# armed seek with its URL keeps that event from eating it — the
# real LoadedMedia lands a few ms later, and until Round 32 the
# start time was silently lost on every track change.
self._pending_start_ms = start_ms
self._pending_start_url = url
self._media.setSource(url)
if autoplay:
self._media.play()
@@ -236,11 +248,15 @@ class LocalSink(PlaybackSink):
def _on_media_status(self, status):
if (status == QMediaPlayer.MediaStatus.LoadedMedia
and self._pending_start_ms):
and self._pending_start_ms
and self._media.source() == self._pending_start_url):
# The media is only reliably seekable once loaded; jump to the
# custom start time now (works whether or not we're autoplaying).
# The source check rejects the stale LoadedMedia that setSource()
# fires for the media being replaced.
start = self._pending_start_ms
self._pending_start_ms = 0
self._pending_start_url = QUrl()
self._media.setPosition(start)
return
if status == QMediaPlayer.MediaStatus.BufferedMedia: