fix(player): closing the player stops every renderer, not the believed one

DR-250. The user's diagnosis, and a better fix than modelling the handoff more
carefully: a close that stops only what we believe is playing is fragile by
construction. A close that stops everything is correct whatever the bookkeeping
thinks.

Two places it did not.

The teardown's stop was gated on `didStartNativePlayback &&
!didStopBackendEarly` — flags describing what *this component* started. A
background-audio handoff swaps the renderer underneath them, so after one they
describe a player that is no longer making sound and the stop was skipped
entirely. The audio stream kept running and the mini player adopted it, which
is exactly why a movie reappeared as an audio track. It is unconditional now;
`playerStop` is idempotent, so the cost of calling it when nothing plays is a
no-op round trip, against the alternative of silently leaving audio running.

And `PlayerController::stop` never cleared the handoff. Leaving the base offset
and the active flag behind lets a later position read be interpreted against a
handoff that no longer exists. Stopping now clears both.

`didStartNativePlayback` had no remaining reader and is deleted rather than
silenced — dead bookkeeping about which renderer was in charge is precisely the
frontend playback state this contract is meant to remove, and the eslint
ratchet caught it going one over.

The underlying unconfirmed state swap is still there and still worth fixing —
it is written up in media-player-controller.md. This makes the symptom
impossible while that lands.

789 Rust tests, 1088 frontend, lint back at 158, clippy clean both ways.
This commit is contained in:
2026-08-23 09:02:30 +02:00
parent 888f0a2a5d
commit 5b5162dd1e
3 changed files with 42 additions and 13 deletions
+22
View File
@@ -789,6 +789,28 @@ impl PlayerController {
drop(backend);
self.clear_reported_time();
// Stopping means *nothing is playing*, from any renderer — not "the
// thing we currently believe owns playback has been asked to stop".
//
// A background-audio handoff swaps which renderer that is, and the swap
// is bookkeeping that can be mid-flight: `exit_background_audio` marks
// the webview element the player again the moment it is called, while
// the element has not reloaded yet. A stop aimed at what the flags say
// is playing therefore misses the audio stream that actually is, and it
// resurfaces in the mini player as an audio track.
//
// Clearing the handoff here is the other half of that: a stop that
// leaves the base offset and the active flag behind lets the next
// position read be interpreted against a handoff that no longer exists.
//
// TRACES: UR-040, UR-005 | DR-250
if self.is_background_audio_active() {
debug!("[PlayerController] stop: clearing an active background-audio handoff");
}
*self.background_audio_active.lock_safe() = false;
self.set_background_audio_base(0.0);
*self.html5_playing.lock_safe() = None;
if let Some(jellyfin_id) = jellyfin_id {
self.report_stopped_at(jellyfin_id, position);
}