fix(player): recover a failed stream on Linux instead of stopping (DR-130)
A recoverable player error meant "playback is over": the frontend's error handler stopped the player unconditionally, so a wifi blip killed the track. Android already decides in its JNI callback, but MpvBackend is constructed before PlayerController exists, so its event thread has no controller to ask. So MPV reports the failure and the frontend echoes it into the new player_recover_stream command — the same shape as PlaybackEnded -> player_on_playback_ended, keeping the decision in Rust. The command re-opens the stream where it stopped, with the existing attempt budget and backoff, and returns whether it handled it; only a false answer falls through to the old stop path. Android now reports the errors it has already declined as *unrecoverable*, so the echo never asks the same question twice. TRACES: UR-004, UR-040 | DR-130 | UT-117
This commit is contained in:
@@ -145,3 +145,76 @@ describe("Player Events — pause must not zero the slider duration", () => {
|
||||
expect(get(playbackDuration)).toBe(70);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A recoverable error is a network hiccup, not the end of playback. The handler
|
||||
* used to stop the player unconditionally, so a blip on wifi killed the track —
|
||||
* on Linux especially, where MPV's EndFile(ERROR) is the only signal a stream
|
||||
* died and there is no in-process controller for the backend to consult.
|
||||
*
|
||||
* TRACES: UR-004, UR-040 | DR-130
|
||||
*/
|
||||
describe("Player Events — recoverable errors get one chance before stopping", () => {
|
||||
beforeEach(async () => {
|
||||
const { cleanupPlayerEvents } = await import("./playerEvents");
|
||||
const { player } = await import("$lib/stores/player");
|
||||
cleanupPlayerEvents();
|
||||
player.setIdle();
|
||||
vi.clearAllMocks();
|
||||
registeredHandler = null;
|
||||
currentQueueItemStore.set(null);
|
||||
});
|
||||
|
||||
it("does not stop the player when Rust re-opened the stream", async () => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
vi.mocked(invoke).mockImplementation(async (cmd: string) =>
|
||||
cmd === "player_recover_stream" ? true : null
|
||||
);
|
||||
|
||||
const { initPlayerEvents } = await import("./playerEvents");
|
||||
const { player } = await import("$lib/stores/player");
|
||||
await initPlayerEvents();
|
||||
await fire({ type: "state_changed", state: "playing", media_id: "track-1" });
|
||||
|
||||
await fire({ type: "error", message: "Playback stream failed", recoverable: true });
|
||||
|
||||
const calls = vi.mocked(invoke).mock.calls.map(([cmd]) => cmd);
|
||||
expect(calls).toContain("player_recover_stream");
|
||||
expect(calls).not.toContain("player_stop");
|
||||
expect(get(player).state.kind).not.toBe("error");
|
||||
});
|
||||
|
||||
it("stops the player when recovery declines", async () => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
vi.mocked(invoke).mockImplementation(async (cmd: string) =>
|
||||
cmd === "player_recover_stream" ? false : null
|
||||
);
|
||||
|
||||
const { initPlayerEvents } = await import("./playerEvents");
|
||||
await initPlayerEvents();
|
||||
|
||||
await fire({ type: "error", message: "Playback stream failed", recoverable: true });
|
||||
await Promise.resolve();
|
||||
|
||||
const calls = vi.mocked(invoke).mock.calls.map(([cmd]) => cmd);
|
||||
expect(calls).toContain("player_recover_stream");
|
||||
expect(calls).toContain("player_stop");
|
||||
});
|
||||
|
||||
it("does not attempt recovery for an unrecoverable error", async () => {
|
||||
// Android decides in its JNI callback and reports the errors it already
|
||||
// declined as unrecoverable, so this must not ask a second time.
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
|
||||
const { initPlayerEvents } = await import("./playerEvents");
|
||||
await initPlayerEvents();
|
||||
|
||||
await fire({ type: "error", message: "Decoder failed", recoverable: false });
|
||||
await Promise.resolve();
|
||||
|
||||
const calls = vi.mocked(invoke).mock.calls.map(([cmd]) => cmd);
|
||||
expect(calls).not.toContain("player_recover_stream");
|
||||
expect(calls).toContain("player_stop");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user