From 40bfa8412ad5ac9607294ccc985743506b36f2f6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 8 Aug 2026 10:44:08 +0200 Subject: [PATCH] fix(playback): harden external player lifecycle --- .changes/playback-external-launch-feedback.md | 2 +- AGENTS.md | 10 +-- CLAUDE.md | 10 +-- .../src/dash-clearkey.e2e.ts | 32 ++++++++- .../src/app/events/external-player-process.ts | 42 ++++++++++++ .../app/events/mpv-session.service.spec.ts | 36 ++++++++++ .../src/app/events/mpv-session.service.ts | 19 ++++-- .../vlc-session.service.lifecycle.spec.ts | 66 ++++++++++++++++++- .../src/app/events/vlc-session.service.ts | 17 +++-- apps/web/src/assets/i18n/ar.json | 4 +- apps/web/src/assets/i18n/ary.json | 4 +- apps/web/src/assets/i18n/by.json | 4 +- apps/web/src/assets/i18n/de.json | 4 +- apps/web/src/assets/i18n/el.json | 4 +- apps/web/src/assets/i18n/en.json | 4 +- apps/web/src/assets/i18n/es.json | 4 +- apps/web/src/assets/i18n/fr.json | 4 +- apps/web/src/assets/i18n/hu.json | 4 +- apps/web/src/assets/i18n/it.json | 4 +- apps/web/src/assets/i18n/ja.json | 4 +- apps/web/src/assets/i18n/ko.json | 4 +- apps/web/src/assets/i18n/nl.json | 4 +- apps/web/src/assets/i18n/pl.json | 4 +- apps/web/src/assets/i18n/pt.json | 4 +- apps/web/src/assets/i18n/ru.json | 4 +- apps/web/src/assets/i18n/tr.json | 4 +- apps/web/src/assets/i18n/zh.json | 4 +- apps/web/src/assets/i18n/zhtw.json | 4 +- docs/architecture/embedded-inline-playback.md | 9 ++- .../external-playback-dock.component.scss | 59 ++++++++--------- .../external-playback-dock.component.spec.ts | 6 +- .../external-playback-recovery.spec.ts | 24 +++++++ .../external-playback-recovery.ts | 2 +- 33 files changed, 307 insertions(+), 103 deletions(-) create mode 100644 apps/electron-backend/src/app/events/external-player-process.ts diff --git a/.changes/playback-external-launch-feedback.md b/.changes/playback-external-launch-feedback.md index b25648a9e..75695488d 100644 --- a/.changes/playback-external-launch-feedback.md +++ b/.changes/playback-external-launch-feedback.md @@ -3,4 +3,4 @@ type: fix area: playback --- -MPV and VLC recovery actions now stay available after launch attempts, show precise opening, started, playing, and failure feedback, and prevent overlapping external-player launches. Failed launches remain visible in the playback dock until dismissed. +MPV and VLC recovery actions now stay available after launch attempts, show precise opening, started, playing, and failure feedback, and prevent overlapping external-player launches. External-player errors remain visible in the playback dock until dismissed. diff --git a/AGENTS.md b/AGENTS.md index e541a15a8..6ffecf202 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -291,10 +291,12 @@ Key files: evidence or ownership state. MPV/VLC actions remain mounted after an attempt and expose credential-free per-target launching/started/playing/error state; only an exact Electron `playing` update is labelled Playing. One handshake is - allowed at a time, switching players first closes the tracked live external - session, and stale updates cannot take ownership. The global dock mirrors - those statuses, keeps errors visible until dismissal, and intentionally has - no retry because it does not own the original launch headers or credentials. + allowed at a time, a replacement waits for confirmed teardown of the tracked + external process, and stale updates cannot take ownership. If the local + handshake times out after an exact Electron session is known, that ID remains + correlated so a later exact update can recover the UI. The global dock mirrors + those statuses, keeps errors visible until dismissal, and intentionally has no + retry because it does not own the original launch headers or credentials. - The built-in HTML5/hls.js player is the second guarded consumer. `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared diff --git a/CLAUDE.md b/CLAUDE.md index 0e3548911..6e1e470f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -827,10 +827,12 @@ app as a real argument, so it is not an option. evidence or ownership state. MPV/VLC actions remain mounted after an attempt and expose credential-free per-target launching/started/playing/error state; only an exact Electron `playing` update is labelled Playing. One handshake is - allowed at a time, switching players first closes the tracked live external - session, and stale updates cannot take ownership. The global dock mirrors - those statuses, keeps errors visible until dismissal, and intentionally has - no retry because it does not own the original launch headers or credentials. + allowed at a time, a replacement waits for confirmed teardown of the tracked + external process, and stale updates cannot take ownership. If the local + handshake times out after an exact Electron session is known, that ID remains + correlated so a later exact update can recover the UI. The global dock mirrors + those statuses, keeps errors visible until dismissal, and intentionally has no + retry because it does not own the original launch headers or credentials. - DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded Shaka Player source engine inside the HTML5 and ArtPlayer components (no new player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*` diff --git a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts index ab71c2132..dae1e6a32 100644 --- a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts +++ b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts @@ -569,6 +569,36 @@ test('@electron @dash ClearKey DASH filters DRM fallback and reports external la /web-player-diagnostic__player-card--primary/ ); await expect(dock).toContainText('Player started'); + const dockThemeStyles = await dock + .locator('.external-playback-dock') + .evaluate((element) => { + const body = element.ownerDocument.body; + const wasDark = body.classList.contains('dark-theme'); + const read = () => { + const style = getComputedStyle(element); + return { + borderColor: style.borderColor, + color: style.color, + onSurface: style + .getPropertyValue('--app-heading-color') + .trim(), + surface: style + .getPropertyValue('--app-widget-bg') + .trim(), + }; + }; + body.classList.remove('dark-theme'); + const light = read(); + body.classList.add('dark-theme'); + const dark = read(); + body.classList.toggle('dark-theme', wasDark); + return { dark, light }; + }); + expect(dockThemeStyles.light.surface).not.toBe(''); + expect(dockThemeStyles.dark.surface).not.toBe(''); + expect(dockThemeStyles.light.onSurface).not.toBe(''); + expect(dockThemeStyles.dark.onSurface).not.toBe(''); + expect(dockThemeStyles.light).not.toEqual(dockThemeStyles.dark); await vlcFallback.click(); const expectedBothLaunches = [ @@ -588,7 +618,7 @@ test('@electron @dash ClearKey DASH filters DRM fallback and reports external la await expect(mpvFallback).toBeVisible(); await expect(vlcFallback).toBeVisible(); await expect(vlcFallback).toContainText('Try VLC again'); - await expect(vlcFallback).toContainText('Player launch failed'); + await expect(vlcFallback).toContainText('External player error'); await expect(mpvFallback).toHaveClass( /web-player-diagnostic__player-card--primary/ ); diff --git a/apps/electron-backend/src/app/events/external-player-process.ts b/apps/electron-backend/src/app/events/external-player-process.ts new file mode 100644 index 000000000..461bdc93a --- /dev/null +++ b/apps/electron-backend/src/app/events/external-player-process.ts @@ -0,0 +1,42 @@ +import type { ChildProcess } from 'child_process'; + +/** + * Resolves only after the exact child has stopped. A sent termination signal + * is not itself proof that a replacement can be started safely. + */ +export function waitForExternalPlayerProcessExit( + child: ChildProcess +): Promise { + if (hasExited(child)) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + const complete = () => { + child.off('exit', complete); + child.off('error', complete); + resolve(); + }; + child.once('exit', complete); + child.once('error', complete); + }); +} + +export function terminateExternalPlayerProcess( + child: ChildProcess +): Promise { + const exited = waitForExternalPlayerProcessExit(child); + if (!hasExited(child) && !child.killed) { + try { + child.kill(); + } catch { + // Keep waiting: failing closed prevents a replacement launch from + // overlapping a child whose teardown could not be confirmed. + } + } + return exited; +} + +function hasExited(child: ChildProcess): boolean { + return child.exitCode !== null || child.signalCode !== null; +} diff --git a/apps/electron-backend/src/app/events/mpv-session.service.spec.ts b/apps/electron-backend/src/app/events/mpv-session.service.spec.ts index 3535289a4..4027d3b04 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.spec.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.spec.ts @@ -49,13 +49,16 @@ import { VLC_REUSE_INSTANCE, store, } from '../services/store.service'; +import { externalPlayerSessions } from './external-player-runtime'; import { openMpvPlayer, shutdownMpvSession } from './mpv-session.service'; import { openVlcPlayer, shutdownVlcSession } from './vlc-session.service'; function createMockChildProcess(): ChildProcess { return Object.assign(new EventEmitter(), { + exitCode: null, killed: false, kill: jest.fn(() => true), + signalCode: null, stderr: null, stdout: null, unref: jest.fn(), @@ -222,6 +225,39 @@ describe('external player shutdown on app quit', () => { expect(proc.kill).not.toHaveBeenCalled(); }); + it('waits for a detached MPV process to exit before closing its session', async () => { + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: false, + }); + const session = await openMpvPlayer({ + title: 'Detached MPV stream', + url: 'https://example.com/live.m3u8', + }); + + let closeSettled = false; + const closePromise = externalPlayerSessions + .closeSession(session.id) + .then((closed) => { + closeSettled = true; + return closed; + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(proc.kill).toHaveBeenCalledTimes(1); + expect(closeSettled).toBe(false); + + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + + await expect(closePromise).resolves.toMatchObject({ + id: session.id, + status: 'closed', + }); + }); + it('kills the stored reusable VLC process on shutdown', async () => { const proc = createMockChildProcess(); (spawn as unknown as jest.Mock).mockReturnValue(proc); diff --git a/apps/electron-backend/src/app/events/mpv-session.service.ts b/apps/electron-backend/src/app/events/mpv-session.service.ts index 8492a94ac..c1346e81f 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.ts @@ -29,6 +29,10 @@ import { sendPlayerErrorNotification, traceExternalPlayer, } from './external-player-runtime'; +import { + terminateExternalPlayerProcess, + waitForExternalPlayerProcessExit, +} from './external-player-process'; export interface OpenExternalPlayerRequest { url: string; @@ -280,6 +284,7 @@ export async function openMpvPlayer({ mpvSocketPath ) { traceExternalPlayer('reuse existing mpv instance'); + const reusedProcess = mpvProcess; try { if (effectiveUserAgent) { await sendMpvCommand('set_property', [ @@ -317,10 +322,10 @@ export async function openMpvPlayer({ try { await sendMpvCommand('quit', []); } catch { - if (mpvProcess && !mpvProcess.killed) { - mpvProcess.kill(); - } + await terminateExternalPlayerProcess(reusedProcess); + return; } + await waitForExternalPlayerProcessExit(reusedProcess); }); if (startTime) { @@ -373,7 +378,9 @@ export async function openMpvPlayer({ } if (headerFields.length > 0) { - args.push(`--http-header-fields=${joinMpvHeaderFields(headerFields)}`); + args.push( + `--http-header-fields=${joinMpvHeaderFields(headerFields)}` + ); } if (title) { @@ -500,9 +507,7 @@ export async function openMpvPlayer({ } externalPlayerSessions.attachCloser(session.id, async () => { - if (!proc.killed) { - proc.kill(); - } + await terminateExternalPlayerProcess(proc); }); if (useMpvSocketBridge && contentInfo && socketPath) { diff --git a/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts b/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts index b78363f61..ccc6edb25 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts @@ -52,8 +52,10 @@ const rcWrites: string[] = []; function createMockChildProcess(): ChildProcess { return Object.assign(new EventEmitter(), { + exitCode: null, killed: false, kill: jest.fn(() => true), + signalCode: null, stderr: null, stdout: null, unref: jest.fn(), @@ -163,6 +165,37 @@ describe('vlc-session.service process lifecycle', () => { expect(session.status).toBe('opened'); }); + it('quits a reused VLC process and waits for its exact exit on close', async () => { + const proc = createMockChildProcess(); + await openTrackedVlcInstance(proc); + installRcSocketMock('ack'); + const session = await openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + }); + rcWrites.length = 0; + + let closeSettled = false; + const closePromise = externalPlayerSessions + .closeSession(session.id) + .then((closed) => { + closeSettled = true; + return closed; + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(rcWrites).toEqual(['quit\n']); + expect(closeSettled).toBe(false); + + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + + await expect(closePromise).resolves.toMatchObject({ + id: session.id, + status: 'closed', + }); + }); + it('kills the stale instance and spawns fresh when RC reuse fails', async () => { const proc = createMockChildProcess(); await openTrackedVlcInstance(proc); @@ -184,6 +217,35 @@ describe('vlc-session.service process lifecycle', () => { }); describe('process exit handling', () => { + it('waits for a detached VLC process to exit before closing its session', async () => { + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + const openPromise = openVlcPlayer({ title: 'S', url: streamUrl }); + await waitForSpawnCallCount(1); + proc.emit('spawn'); + const session = await openPromise; + + let closeSettled = false; + const closePromise = externalPlayerSessions + .closeSession(session.id) + .then((closed) => { + closeSettled = true; + return closed; + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(proc.kill).toHaveBeenCalledTimes(1); + expect(closeSettled).toBe(false); + + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); + + await expect(closePromise).resolves.toMatchObject({ + id: session.id, + status: 'closed', + }); + }); + it('marks the session closed on a clean exit', async () => { const proc = createMockChildProcess(); spawnMock.mockReturnValueOnce(proc); @@ -262,9 +324,7 @@ describe('vlc-session.service process lifecycle', () => { }); const proc = createMockChildProcess(); const retryProc = createMockChildProcess(); - spawnMock - .mockReturnValueOnce(proc) - .mockReturnValueOnce(retryProc); + spawnMock.mockReturnValueOnce(proc).mockReturnValueOnce(retryProc); const openPromise = openVlcPlayer({ title: 'S', url: streamUrl }); await waitForSpawnCallCount(1); diff --git a/apps/electron-backend/src/app/events/vlc-session.service.ts b/apps/electron-backend/src/app/events/vlc-session.service.ts index a8ad22e6a..9630b26f2 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.ts @@ -28,6 +28,10 @@ import { sendPlayerErrorNotification, traceExternalPlayer, } from './external-player-runtime'; +import { + terminateExternalPlayerProcess, + waitForExternalPlayerProcessExit, +} from './external-player-process'; export interface OpenVlcPlayerRequest { url: string; @@ -362,6 +366,7 @@ export async function openVlcPlayer({ traceExternalPlayer('reuse existing vlc instance', { rcPort: vlcRcPort, }); + const reusedProcess = vlcProcess; try { const enqueueCommands = buildVlcEnqueueCommands({ url, @@ -379,12 +384,12 @@ export async function openVlcPlayer({ let lastReusedSnapshot: ExternalPlaybackSnapshot | null = null; externalPlayerSessions.attachCloser(session.id, async () => { try { - await sendVlcRcCommand(reusedRcPort, 'stop'); + await sendVlcRcCommand(reusedRcPort, 'quit'); } catch { - if (vlcProcess && !vlcProcess.killed) { - vlcProcess.kill(); - } + await terminateExternalPlayerProcess(reusedProcess); + return; } + await waitForExternalPlayerProcessExit(reusedProcess); }); if (contentInfo) { @@ -573,9 +578,7 @@ export async function openVlcPlayer({ externalPlayerSessions.attachCloser(session.id, async () => { await flushVlcPlaybackPosition(); - if (!proc.killed) { - proc.kill(); - } + await terminateExternalPlayerProcess(proc); }); if (!isRetry && rcPort > 0 && contentInfo) { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index db3b1494e..2c534b2eb 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "جميع القنوات", "RAIL_GROUPS": "المجموعات", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 264a2a73c..f3c638c5c 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "جميع القنوات", "RAIL_GROUPS": "المجموعات", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 61a76161b..96f173d40 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Усе каналы", "RAIL_GROUPS": "Групы", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 929bd5e2f..5a54a010d 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Player wird geöffnet…", "EXTERNAL_STARTED": "Player gestartet", "EXTERNAL_PLAYING": "Wiedergabe läuft", - "EXTERNAL_FAILED": "Player konnte nicht gestartet werden", + "EXTERNAL_FAILED": "Fehler im externen Player", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Player wird geöffnet…", "EXTERNAL_PLAYBACK_STARTED": "Player gestartet", "EXTERNAL_PLAYBACK_PLAYING": "Wiedergabe läuft", - "EXTERNAL_PLAYBACK_FAILED": "Wiedergabe fehlgeschlagen", + "EXTERNAL_PLAYBACK_FAILED": "Fehler im externen Player", "EXTERNAL_PLAYBACK_CLOSED": "Geschlossen", "RAIL_ALL_CHANNELS": "Alle Sender", "RAIL_GROUPS": "Gruppen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index b6a864aba..2cefa7898 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Όλα τα κανάλια", "RAIL_GROUPS": "Ομάδες", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index edb0867a8..f35662d4f 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy stream URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "All channels", "RAIL_GROUPS": "Groups", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index f73d1607e..691d75580 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Todos los canales", "RAIL_GROUPS": "Grupos", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 6939bbac0..cae45cc6e 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Toutes les chaînes", "RAIL_GROUPS": "Groupes", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 2d11bd679..15602430b 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "beállított lejátszó", "ACTION_OPEN_VLC_HINT": "beállított lejátszó", "ACTION_COPY_URL": "Adatfolyam URL-címének másolása", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Összes csatorna", "RAIL_GROUPS": "Csoportok", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 82a1c95df..d1c5ae2b7 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Tutti i canali", "RAIL_GROUPS": "Gruppi", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 663419b10..6f687b8c2 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "すべてのチャンネル", "RAIL_GROUPS": "グループ", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index ca62ec3c7..23d63b858 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "모든 채널", "RAIL_GROUPS": "그룹", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 5e8224146..2b80527b1 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Alle kanalen", "RAIL_GROUPS": "Groepen", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index ed9ffe26b..36d1b3dfd 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Wszystkie kanały", "RAIL_GROUPS": "Grupy", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 0b83dea32..79d89b736 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Todos os canais", "RAIL_GROUPS": "Grupos", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index bcf5d32dd..9cf9ba21b 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Плеер открывается…", "EXTERNAL_STARTED": "Плеер запущен", "EXTERNAL_PLAYING": "Воспроизведение", - "EXTERNAL_FAILED": "Не удалось запустить плеер", + "EXTERNAL_FAILED": "Ошибка внешнего плеера", "ACTION_OPEN_MPV_HINT": "настроенный плеер", "ACTION_OPEN_VLC_HINT": "настроенный плеер", "ACTION_COPY_URL": "Скопировать URL потока", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Плеер открывается…", "EXTERNAL_PLAYBACK_STARTED": "Плеер запущен", "EXTERNAL_PLAYBACK_PLAYING": "Воспроизведение", - "EXTERNAL_PLAYBACK_FAILED": "Не удалось запустить плеер", + "EXTERNAL_PLAYBACK_FAILED": "Ошибка внешнего плеера", "EXTERNAL_PLAYBACK_CLOSED": "Закрыт", "RAIL_ALL_CHANNELS": "Все каналы", "RAIL_GROUPS": "Группы", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 07384c2c1..d37b02cf6 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "Tüm kanallar", "RAIL_GROUPS": "Gruplar", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 0c1c9e6eb..855bda9e6 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "所有频道", "RAIL_GROUPS": "分组", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 54998a2f0..aae2dd65a 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -592,7 +592,7 @@ "EXTERNAL_OPENING": "Opening player…", "EXTERNAL_STARTED": "Player started", "EXTERNAL_PLAYING": "Playing", - "EXTERNAL_FAILED": "Player launch failed", + "EXTERNAL_FAILED": "External player error", "ACTION_OPEN_MPV_HINT": "configured player", "ACTION_OPEN_VLC_HINT": "configured player", "ACTION_COPY_URL": "Copy URL", @@ -1312,7 +1312,7 @@ "EXTERNAL_PLAYBACK_OPENING": "Opening player…", "EXTERNAL_PLAYBACK_STARTED": "Player started", "EXTERNAL_PLAYBACK_PLAYING": "Playing", - "EXTERNAL_PLAYBACK_FAILED": "Playback failed", + "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", "RAIL_ALL_CHANNELS": "所有頻道", "RAIL_GROUPS": "群組", diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 0544ae917..3def9343e 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -661,11 +661,16 @@ target becomes an explicit reopen action and a failed target becomes Try again. Only an exact correlated Electron `playing` session update earns the Playing label. A single external launch handshake owns the session: duplicate actions are ignored, other external actions wait, and an existing live external session -must close before a different player can start. +must close before a different player can start. A replacement does not launch +until teardown of the exact spawned process is confirmed. If the local +handshake timeout fires after Electron has supplied an exact session ID, the ID +stays correlated so a later `opened`, `playing`, or `error` update can reconcile +the action with the global dock. The global external-playback dock uses the same Electron session status. It shows Opening player with progress during launch, Player started for `opened`, -and Playing only for `playing`. Launch errors remain visible until dismissed; +and Playing only for `playing`. External player errors remain visible until +dismissed, whether startup failed or a started player exited unexpectedly; the dock deliberately has no retry because it does not own the original headers or credentials required to reconstruct a safe launch request. diff --git a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss index 29158fd71..050c47f23 100644 --- a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss +++ b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss @@ -9,27 +9,27 @@ gap: 14px; min-height: 72px; padding: 2px 14px; - border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 60%, transparent); + border: 1px solid var(--app-widget-header-border, rgba(0, 0, 0, 0.08)); border-radius: 15px 15px 0 0; - background: - linear-gradient( - 135deg, - color-mix(in srgb, var(--mat-sys-surface-container-high) 88%, white 12%), - color-mix(in srgb, var(--mat-sys-surface-container) 92%, transparent) - ); - box-shadow: - 0 18px 42px rgba(15, 23, 42, 0.12), - inset 0 1px 0 rgba(255, 255, 255, 0.28); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--app-widget-bg, #fff) 88%, white 12%), + var(--app-widget-header-bg, #fafbfc) + ); + box-shadow: var(--app-widget-shadow, 0 18px 42px rgba(15, 23, 42, 0.12)); } .external-playback-dock--error { - border-color: color-mix(in srgb, var(--mat-sys-error) 50%, transparent); - background: - linear-gradient( - 135deg, - color-mix(in srgb, var(--mat-sys-error-container) 72%, transparent), - color-mix(in srgb, var(--mat-sys-surface-container) 90%, transparent) - ); + border-color: color-mix( + in srgb, + var(--app-expiry-expired-text, #b3261e) 50%, + transparent + ); + background: linear-gradient( + 135deg, + var(--app-expiry-expired-surface, rgba(244, 67, 54, 0.14)), + var(--app-widget-bg, #fff) + ); } .external-playback-dock--compact { @@ -37,9 +37,7 @@ padding: 8px 10px; gap: 10px; border-radius: 16px; - box-shadow: - 0 12px 28px rgba(15, 23, 42, 0.16), - inset 0 1px 0 rgba(255, 255, 255, 0.18); + box-shadow: var(--app-widget-shadow, 0 12px 28px rgba(15, 23, 42, 0.16)); .external-playback-dock__artwork { width: 38px; @@ -63,7 +61,7 @@ border: 0; border-radius: 14px; overflow: hidden; - background: color-mix(in srgb, var(--mat-sys-surface-container-highest) 92%, transparent); + background: var(--app-widget-header-bg, #fafbfc); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18); color: inherit; cursor: default; @@ -93,7 +91,7 @@ } &:focus-visible { - outline: 2px solid var(--mat-sys-primary); + outline: 2px solid var(--app-selection-color, #2f7bff); outline-offset: 2px; } } @@ -102,7 +100,7 @@ .external-playback-dock__placeholder { display: grid; place-items: center; - color: var(--mat-sys-on-surface-variant); + color: var(--app-body-color, #4a5060); } .external-playback-dock__meta { @@ -114,14 +112,9 @@ align-items: center; height: 18px; border-radius: 999px; - background: color-mix( - in srgb, - var(--mat-sys-surface-container-highest) 80%, - transparent - ); - color: var(--mat-sys-on-surface-variant); - border: 1px solid - color-mix(in srgb, var(--mat-sys-outline-variant) 70%, transparent); + background: var(--app-card-hover-bg, #f0f2f5); + color: var(--app-body-color, #4a5060); + border: 1px solid var(--app-widget-header-border, rgba(0, 0, 0, 0.08)); font-size: 0.66rem; font-weight: 700; letter-spacing: 0.08em; @@ -133,7 +126,7 @@ white-space: nowrap; font-size: 0.95rem; font-weight: 700; - color: var(--mat-sys-on-surface); + color: var(--app-heading-color, #1a1d23); margin-bottom: 2px; } @@ -141,7 +134,7 @@ display: flex; align-items: center; gap: 6px; - color: var(--mat-sys-on-surface-variant); + color: var(--app-body-color, #4a5060); font-size: 0.78rem; min-width: 0; diff --git a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts index f4bc1ff27..cd951eecc 100644 --- a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts +++ b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts @@ -21,7 +21,7 @@ class FakeTranslateLoader implements TranslateLoader { EXTERNAL_PLAYBACK_OPENING: 'Opening player…', EXTERNAL_PLAYBACK_STARTED: 'Player started', EXTERNAL_PLAYBACK_PLAYING: 'Playing', - EXTERNAL_PLAYBACK_FAILED: 'Playback failed', + EXTERNAL_PLAYBACK_FAILED: 'External player error', }, }, }); @@ -140,7 +140,9 @@ describe('ExternalPlaybackDockComponent', () => { const action = fixture.debugElement.query( By.css('.external-playback-dock__button') ); - expect(fixture.nativeElement.textContent).toContain('Playback failed'); + expect(fixture.nativeElement.textContent).toContain( + 'External player error' + ); expect(action.nativeElement.textContent).toContain('Dismiss'); expect(action.nativeElement.textContent).not.toContain('Retry'); expect(action.nativeElement.getAttribute('aria-label')).toBe('Dismiss'); diff --git a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts index ae34b18d1..26e61df7f 100644 --- a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts @@ -134,6 +134,30 @@ describe('ExternalPlaybackRecovery', () => { recovery.destroy(); }); + it('keeps an exact launching session correlated after the local timeout', () => { + const recovery = new ExternalPlaybackRecovery(); + recovery.syncSession('content-a'); + recovery.begin('mpv', null); + recovery.observe( + session({ id: 'slow-mpv', player: 'mpv', status: 'launching' }) + ); + + jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS); + + expect(recovery.target('mpv')).toEqual({ + attempts: 1, + sessionId: 'slow-mpv', + status: 'error', + }); + expect( + recovery.observe( + session({ id: 'slow-mpv', player: 'mpv', status: 'playing' }) + ) + ).toBe(true); + expect(recovery.target('mpv').status).toBe('playing'); + recovery.destroy(); + }); + it('invalidates an old intent and timer when the content session changes', () => { const recovery = new ExternalPlaybackRecovery(); recovery.syncSession('content-a'); diff --git a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts index b5744840a..001c7363d 100644 --- a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts +++ b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts @@ -138,7 +138,7 @@ export class ExternalPlaybackRecovery { const current = this.target(intent.target); this.setTarget(intent.target, { attempts: current.attempts, - sessionId: null, + sessionId: current.sessionId, status: 'error', }); this.clearActiveIntent();