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
+19 -13
View File
@@ -251,7 +251,6 @@
function nativeSeekSettling(): boolean {
return Date.now() - lastNativeSeekAt < NATIVE_SEEK_SETTLE_MS;
}
let didStartNativePlayback = $state(false); // Track if we started playback (to know if we should stop on unmount)
let didStopBackendEarly = $state(false); // Track if we stopped backend early for non-transcoded content
let swipeType = $state<"brightness" | null>(null);
let hls: Hls | null = null; // HLS.js instance for streaming HLS content
@@ -1032,7 +1031,6 @@
"Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions",
);
// Backend is kept running but should not play audio since HTML5 element handles playback
didStartNativePlayback = true; // Track that we need to stop backend on unmount
}
// Register the adapter with the facade so control intents (UI, or a
@@ -1098,7 +1096,6 @@
if (!useHtml5Element) {
// Using native backend, subscribe to player events
didStartNativePlayback = true; // Track that we started native playback
isPlaying = (response.state?.kind ?? response.state) === "playing";
// Cleanup happens in the component's top-level onDestroy. Calling
// onDestroy() here — after an await — throws lifecycle_outside_component,
@@ -1139,7 +1136,6 @@
}
} else {
// For transcoded content, keep backend for seeking
didStartNativePlayback = true;
}
}
}
@@ -1273,14 +1269,25 @@
}
// Stop the player when component is destroyed
// Skip if we already stopped the backend early (non-transcoded + HTML5)
if (didStartNativePlayback && !didStopBackendEarly) {
try {
log.debug("Stopping backend player on component unmount");
await commands.playerStop();
} catch (err) {
log.error("Failed to stop backend player:", err);
}
// Unconditional. Leaving the player means nothing should still be playing,
// whichever renderer happened to own it.
//
// This used to be 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 the one making sound, and the stop was skipped
// while the audio stream kept going. It then reappeared in the mini player
// as an audio track.
//
// `playerStop` is idempotent, so calling it when nothing is playing costs a
// no-op IPC round trip. That is a far cheaper failure than the alternative.
//
// TRACES: UR-040, UR-005 | DR-250
try {
log.debug("Stopping backend player on component unmount");
await commands.playerStop();
} catch (err) {
log.error("Failed to stop backend player:", err);
}
// Report stop when component is destroyed (skip for live - no resume tracking)
@@ -2039,7 +2046,6 @@
transport: targetSelection.transport,
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
});
didStartNativePlayback = true;
await playerAdapter?.load(targetSelection.url, {
mediaId: media.id,
selection: targetSelection,