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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user