v0.1.3: start time honored when replaying the already-loaded track
QMediaPlayer.setSource() no-ops on an unchanged URL, so replaying the track that was already the loaded source never re-fired LoadedMedia and the armed _pending_start_ms was silently dropped — a freshly edited start time appeared to save but playback began at 0:00. _load_current now detects the unchanged source and stop()+seeks directly (a forced clear+reload was tried and races the FFmpeg backend, snapping the seek back to 0). previous()-restart likewise rewinds to the custom start time instead of 0:00. Verified live via an offscreen GUI harness: replay-while-playing, replay-after-EndOfMedia, and cleared-start-time replay. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
20
TASKS.md
20
TASKS.md
@ -3,6 +3,26 @@
|
||||
Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
|
||||
When a round closes, move its finished items to `tasks-done.md`.
|
||||
|
||||
## Round 22 — start time honored on same-source replay (v0.1.3)
|
||||
|
||||
Plan reference: `~/.claude/plans/could-you-look-into-generic-balloon.md`.
|
||||
Tests in `tests/test_round22.py`. Fix-only round → patch bump **0.1.3**.
|
||||
|
||||
Background: editing a track's start time saved fine but replaying it still
|
||||
started at 0:00 whenever that track was already the loaded source — Qt's
|
||||
`QMediaPlayer.setSource()` no-ops on an unchanged URL, so the LoadedMedia
|
||||
status change that consumes the armed `_pending_start_ms` never re-fired.
|
||||
(A forced clear+reload was tried first and races the FFmpeg backend: the
|
||||
seek lands, then the pipeline restart snaps position back to 0.)
|
||||
|
||||
- [x] `Player._load_current`: when the source URL is unchanged, skip
|
||||
`setSource` entirely — the media is already loaded — and `stop()` +
|
||||
seek straight to the armed start time. Verified live (offscreen GUI
|
||||
harness): replay-while-playing, replay-after-EndOfMedia, and
|
||||
cleared-start-time replay all land where they should.
|
||||
- [x] `Player.previous()`: the ">3 s in restarts the track" path now seeks
|
||||
to the track's custom start time instead of 0:00.
|
||||
|
||||
## Round 21 — transport buttons fill their bubble (v0.1.2)
|
||||
|
||||
Plan reference: `~/.claude/plans/tap-targets-on-the-snazzy-minsky.md`.
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.1.2"
|
||||
__version__ = "0.1.3"
|
||||
|
||||
@ -239,7 +239,10 @@ class Player(QObject):
|
||||
return
|
||||
prev_index = self._step_index(-1)
|
||||
if self._media.position() > RESTART_THRESHOLD_MS or prev_index is None:
|
||||
self._media.setPosition(0)
|
||||
# "Restart" means the track's custom start time, not 0:00.
|
||||
start = (self._current_track.start_time
|
||||
if self._current_track else 0)
|
||||
self._media.setPosition(max(0, start))
|
||||
else:
|
||||
self._index = prev_index
|
||||
self._load_current(autoplay=self.is_playing())
|
||||
@ -293,7 +296,18 @@ class Player(QObject):
|
||||
else:
|
||||
self._stop_at_ms = 0
|
||||
self._counted_finish_id = None
|
||||
self._media.setSource(QUrl.fromLocalFile(track.location))
|
||||
url = QUrl.fromLocalFile(track.location)
|
||||
if self._media.source() == url:
|
||||
# setSource() no-ops on an unchanged URL, so LoadedMedia never
|
||||
# re-fires and the armed start time would be skipped (and forcing
|
||||
# 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
|
||||
else:
|
||||
self._media.setSource(url)
|
||||
if autoplay:
|
||||
self._media.play()
|
||||
self.track_changed.emit(track)
|
||||
|
||||
122
tests/test_round22.py
Normal file
122
tests/test_round22.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""Round 22: start-time fixes in the player.
|
||||
|
||||
Editing a track's start time saved fine but replaying the *already loaded*
|
||||
track ignored it: QMediaPlayer.setSource() no-ops on an unchanged URL, so the
|
||||
LoadedMedia status change that consumes the armed start time never re-fired
|
||||
(and forcing a clear+reload races the FFmpeg backend, which snaps the seek
|
||||
back to 0). _load_current now detects the unchanged source and seeks directly
|
||||
— the media is already loaded. Also, "previous"-restart now rewinds to the
|
||||
custom start time instead of 0:00.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from PyQt6.QtCore import QUrl
|
||||
|
||||
from lintunes.models import Library, Track
|
||||
from lintunes import player as player_module
|
||||
from lintunes.player import Player, RESTART_THRESHOLD_MS
|
||||
|
||||
|
||||
def _track(tid, **kw):
|
||||
return Track(track_id=tid, name=kw.pop("name", f"Track {tid}"), **kw)
|
||||
|
||||
|
||||
def _player(qapp, tracks):
|
||||
library = Library()
|
||||
for t in tracks:
|
||||
library.tracks[t.track_id] = t
|
||||
manager = MagicMock()
|
||||
manager.library = library
|
||||
with patch.multiple(
|
||||
player_module,
|
||||
QMediaPlayer=MagicMock(),
|
||||
QAudioOutput=MagicMock(),
|
||||
QAudioBufferOutput=MagicMock(),
|
||||
QMediaDevices=MagicMock(),
|
||||
):
|
||||
player = Player(manager)
|
||||
return player, manager
|
||||
|
||||
|
||||
class TestSameSourceReplay:
|
||||
def test_unchanged_url_seeks_to_freshly_edited_start(self, qapp, tmp_path):
|
||||
(tmp_path / "a.mp3").write_bytes(b"x")
|
||||
loc = str(tmp_path / "a.mp3")
|
||||
track = _track(1, location=loc, total_time=200_000)
|
||||
player, _ = _player(qapp, [track])
|
||||
url = QUrl.fromLocalFile(loc)
|
||||
|
||||
player.play_queue([1], 0) # first load: source was empty, plain set
|
||||
assert player._media.setSource.call_args_list[-1].args == (url,)
|
||||
|
||||
# The track is now the loaded source; the user edits its start time
|
||||
# and plays it again. The media is already loaded, so the player must
|
||||
# seek directly instead of waiting for a LoadedMedia that never comes.
|
||||
player._media.source.return_value = url
|
||||
track.start_time = 30_000
|
||||
player._media.setSource.reset_mock()
|
||||
player.play_queue([1], 0)
|
||||
|
||||
player._media.setSource.assert_not_called()
|
||||
player._media.stop.assert_called()
|
||||
player._media.setPosition.assert_called_with(30_000)
|
||||
assert player._pending_start_ms == 0 # consumed
|
||||
player._media.play.assert_called()
|
||||
|
||||
def test_unchanged_url_without_start_time_restarts_at_zero(
|
||||
self, qapp, tmp_path):
|
||||
(tmp_path / "a.mp3").write_bytes(b"x")
|
||||
loc = str(tmp_path / "a.mp3")
|
||||
track = _track(1, location=loc, total_time=200_000)
|
||||
player, _ = _player(qapp, [track])
|
||||
|
||||
player.play_queue([1], 0)
|
||||
player._media.source.return_value = QUrl.fromLocalFile(loc)
|
||||
player._media.setPosition.reset_mock()
|
||||
player.play_queue([1], 0)
|
||||
|
||||
# stop() rewinds to 0; no seek should be issued.
|
||||
player._media.stop.assert_called()
|
||||
player._media.setPosition.assert_not_called()
|
||||
|
||||
def test_different_url_loads_via_setsource(self, qapp, tmp_path):
|
||||
for name in ("a.mp3", "b.mp3"):
|
||||
(tmp_path / name).write_bytes(b"x")
|
||||
t1 = _track(1, location=str(tmp_path / "a.mp3"), total_time=200_000)
|
||||
t2 = _track(2, location=str(tmp_path / "b.mp3"),
|
||||
total_time=200_000, start_time=30_000)
|
||||
player, _ = _player(qapp, [t1, t2])
|
||||
player._media.source.return_value = QUrl.fromLocalFile(t1.location)
|
||||
|
||||
player.play_queue([1, 2], 1) # loading b.mp3 while a.mp3 is the source
|
||||
assert [c.args for c in player._media.setSource.call_args_list] \
|
||||
== [(QUrl.fromLocalFile(t2.location),)]
|
||||
# New source: the seek waits for LoadedMedia as before.
|
||||
assert player._pending_start_ms == 30_000
|
||||
player._on_media_status(player_module.QMediaPlayer.MediaStatus.LoadedMedia)
|
||||
player._media.setPosition.assert_called_with(30_000)
|
||||
assert player._pending_start_ms == 0
|
||||
|
||||
|
||||
class TestPreviousRestart:
|
||||
def test_restart_seeks_to_custom_start_time(self, qapp, tmp_path):
|
||||
(tmp_path / "a.mp3").write_bytes(b"x")
|
||||
track = _track(1, location=str(tmp_path / "a.mp3"),
|
||||
total_time=200_000, start_time=30_000)
|
||||
player, _ = _player(qapp, [track])
|
||||
player.play_queue([1], 0)
|
||||
|
||||
player._media.position.return_value = RESTART_THRESHOLD_MS + 1
|
||||
player.previous()
|
||||
player._media.setPosition.assert_called_with(30_000)
|
||||
|
||||
def test_restart_without_start_time_seeks_to_zero(self, qapp, tmp_path):
|
||||
(tmp_path / "a.mp3").write_bytes(b"x")
|
||||
track = _track(1, location=str(tmp_path / "a.mp3"), total_time=200_000)
|
||||
player, _ = _player(qapp, [track])
|
||||
player.play_queue([1], 0)
|
||||
|
||||
player._media.position.return_value = RESTART_THRESHOLD_MS + 1
|
||||
player.previous()
|
||||
player._media.setPosition.assert_called_with(0)
|
||||
Reference in New Issue
Block a user