From a50c39fa43058d1cefbbf085db39a071a421637b Mon Sep 17 00:00:00 2001 From: trav Date: Sun, 5 Jul 2026 06:28:21 -0400 Subject: [PATCH] v0.1.3: start time honored when replaying the already-loaded track MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- TASKS.md | 20 +++++++ lintunes/__init__.py | 2 +- lintunes/player.py | 18 ++++++- tests/test_round22.py | 122 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 tests/test_round22.py diff --git a/TASKS.md b/TASKS.md index 5e59e94..ab34e96 100644 --- a/TASKS.md +++ b/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`. diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 40b2881..fc547bb 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.1.2" +__version__ = "0.1.3" diff --git a/lintunes/player.py b/lintunes/player.py index 993b3f3..f35f86f 100644 --- a/lintunes/player.py +++ b/lintunes/player.py @@ -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) diff --git a/tests/test_round22.py b/tests/test_round22.py new file mode 100644 index 0000000..e69fbf4 --- /dev/null +++ b/tests/test_round22.py @@ -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)