fix(playback): harden external player lifecycle

This commit is contained in:
4gray committed 2026-08-09 12:52:05 +02:00
1 parent 7ed1ccfd8e
commit 40bfa8412a
33 files changed
+307 -103

No files matched your search

@@ -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.
+6 -4
View File
@@ -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
+6 -4
View File
@@ -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.*`
@@ -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/
);
@@ -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<void> {
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<void> {
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;
}
@@ -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<void>((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);
@@ -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) {
@@ -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<void>((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<void>((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);
@@ -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) {
+2 -2
View File
@@ -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": "المجموعات",
+2 -2
View File
@@ -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": "المجموعات",
+2 -2
View File
@@ -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": "Групы",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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": "Ομάδες",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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": "グループ",
+2 -2
View File
@@ -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": "그룹",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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": "Группы",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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": "分组",
+2 -2
View File
@@ -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": "群組",
@@ -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.
@@ -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;
@@ -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');
@@ -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');
@@ -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();