diff --git a/src/lrx_cli/watch/tracker.py b/src/lrx_cli/watch/tracker.py index 3a8c0b3..ba00794 100644 --- a/src/lrx_cli/watch/tracker.py +++ b/src/lrx_cli/watch/tracker.py @@ -78,12 +78,11 @@ class PositionTracker: if player_changed or track_changed: # reset to 0 so stale position from a previous track doesn't bleed through self._position_ms = 0 - # only poll MPRIS when something changed and the player is actually running; - # avoids an unnecessary D-Bus round-trip on every calibration-loop tick - should_calibrate_now = ( - self._is_playing - and bool(self._active_player) - and (player_changed or track_changed or status_changed_to_playing) + # poll MPRIS on any identity change (player, track, or resume) so a paused + # mid-song player gets its position anchored immediately; calibration-loop + # ticks are excluded because they pass the same player/track/status + should_calibrate_now = bool(self._active_player) and ( + player_changed or track_changed or status_changed_to_playing ) self._track_key = track_key self._last_tick = time.monotonic() diff --git a/tests/test_watch.py b/tests/test_watch.py index d8f8994..371be30 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -149,6 +149,20 @@ def test_position_tracker_resume_via_playback_status_calibrates() -> None: asyncio.run(_run()) +def test_position_tracker_paused_start_calibrates_initial_position() -> None: + """set_active_player with Paused must still calibrate position — player may be mid-song.""" + + async def _run() -> None: + tracker = PositionTracker(lambda _: asyncio.sleep(0, result=45000), TEST_CONFIG) + await tracker.start() + await tracker.set_active_player(BUS, "Paused", "track-A") + pos = await tracker.get_position_ms() + await tracker.stop() + assert pos >= 45000 + + asyncio.run(_run()) + + def test_position_tracker_resume_via_set_active_player_calibrates() -> None: async def _run() -> None: tracker = PositionTracker(lambda _: asyncio.sleep(0, result=42000), TEST_CONFIG) @@ -458,6 +472,66 @@ def test_coordinator_fetches_while_paused() -> None: asyncio.run(_run()) +def test_coordinator_paused_start_emits_correct_line_after_fetch() -> None: + """After fetch completes with a mid-song paused player, the current lyric line must render.""" + + async def _run() -> None: + received: list[WatchState] = [] + + class _CaptureOutput(BaseOutput): + position_sensitive = True + + async def on_state(self, state: WatchState) -> None: + received.append(state) + + class _Manager: + def fetch_for_track(self, *_a, **_kw): + return None + + PAUSED_MS = 45000 + lrc = LRCData("[00:43.00]a\n[00:44.00]b\n[00:46.00]c") + + session = WatchCoordinator( + _Manager(), # type: ignore + _CaptureOutput(), + player_hint=None, + config=TEST_CONFIG, + ) + session._tracker = PositionTracker( + lambda _bus: asyncio.sleep(0, result=PAUSED_MS), + TEST_CONFIG, + ) + await session._tracker.start() + + # Calibrate tracker directly (tracker-level behavior already covered by + # test_position_tracker_paused_start_calibrates_initial_position) + await session._tracker.set_active_player(BUS, "Paused", "Artist - Song") + + # Put model in the state _on_player_change would have produced + session._model.active_player = BUS + session._model.active_track_key = "Artist - Song" + session._model.status = WatchStatus.FETCHING + session._player_monitor.players = {BUS: _pstate("Paused")} + session._last_emit_signature = ( + "status", + WatchStatus.FETCHING, + BUS, + "Artist - Song", + ) + + await session._on_lyrics_update(lrc) + + last_ok = next( + (s for s in reversed(received) if s.status == WatchStatus.OK), None + ) + assert last_ok is not None, "no OK state emitted after lyrics loaded" + assert last_ok.position_ms >= PAUSED_MS + + await session._tracker.stop() + + asyncio.run(_run()) + + def test_coordinator_fetches_on_track_change() -> None: async def _run() -> None: session = _make_coordinator()