feat(embedded-mpv): configurable extra libmpv options and network auto-reconnect (#1515)

Extra libmpv options (Settings > Playback) reach every embedded engine off the command line (createSession array on Windows/macOS, a 0600 --include file on Linux native-view, a stdin preamble for the frame-copy helper); the keys the embed depends on are refused, and keys libmpv rejects are reported once per session. Dropped streams reload automatically (error, or ended on live) with 2 s -> 30 s backoff, six attempts per outage and a 30 s stability reset, only for a load that already played; engine failures stay terminal, a running recording is filed as interrupted and restarted after the reload, and an external subtitle file is re-added. Settings.embeddedMpvAutoReconnect (default on) opts out; the player shows 'Reconnecting... attempt N of M'.

Started by Bpl5966 in #1515 and finished by the maintainers in the same PR.

Co-authored-by: Bpl5966 <amine.b1959@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ZoultandClaude Fable 5.1 authored and GitHub committed 2026-09-04 16:36:14 +02:00
1 parent 6cfdaf5900
commit 5a11b82eaf
66 files changed
+3740 -101

No files matched your search

@@ -137,6 +137,24 @@ describe('SettingsStore dashboard rail settings', () => {
expect(store.getSettings().webPlayerSharedControls).toBe(false);
});
it('defaults embedded MPV auto-reconnect to true when the stored field is missing', async () => {
storedSettings = {};
const store = injector.get(SettingsStore);
await store.loadSettings();
expect(store.getSettings().embeddedMpvAutoReconnect).toBe(true);
});
it('restores a persisted false embedded MPV auto-reconnect opt-out', async () => {
storedSettings = { embeddedMpvAutoReconnect: false };
const store = injector.get(SettingsStore);
await store.loadSettings();
expect(store.getSettings().embeddedMpvAutoReconnect).toBe(false);
});
it('defaults strip country prefix to false when the stored field is missing', async () => {
storedSettings = {};
const store = injector.get(SettingsStore);
@@ -54,6 +54,8 @@ const DEFAULT_SETTINGS: Settings = {
downloadFolder: '',
recordingFolder: '',
embeddedMpvFrameCopy: false,
embeddedMpvExtraOptions: '',
embeddedMpvAutoReconnect: true,
coverSize: 'medium',
epgViewMode: 'timeline',
dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS,
@@ -158,6 +160,9 @@ export const SettingsStore = signalStore(
webPlayerSharedControls:
storedSettings.webPlayerSharedControls !==
false,
embeddedMpvAutoReconnect:
storedSettings.embeddedMpvAutoReconnect !==
false,
dashboardRails: normalizeDashboardRailsSettings(
storedSettings.dashboardRails
),
@@ -192,6 +197,12 @@ export const SettingsStore = signalStore(
settings.webPlayerSharedControls !== false,
}
: {}),
...(settings.embeddedMpvAutoReconnect !== undefined
? {
embeddedMpvAutoReconnect:
settings.embeddedMpvAutoReconnect !== false,
}
: {}),
...(settings.dashboardRails !== undefined
? {
dashboardRails: normalizeDashboardRailsSettings(
@@ -269,6 +280,10 @@ export const SettingsStore = signalStore(
DEFAULT_SETTINGS.recordingFolder,
embeddedMpvFrameCopy:
store.embeddedMpvFrameCopy?.() ?? false,
embeddedMpvExtraOptions:
store.embeddedMpvExtraOptions?.() ?? '',
embeddedMpvAutoReconnect:
store.embeddedMpvAutoReconnect?.() !== false,
coverSize:
store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize,
epgViewMode:
+1
View File
@@ -14,6 +14,7 @@ export * from './lib/epg-item.interface';
export * from './lib/epg-mapping-key.util';
export * from './lib/epg-program.model';
export * from './lib/external-player-arguments.utils';
export * from './lib/embedded-mpv-extra-options.util';
export * from './lib/external-player-session.interface';
export * from './lib/global-search-result.interface';
export * from './lib/host-connectivity.util';
@@ -0,0 +1,83 @@
import {
EMBEDDED_MPV_NETWORK_DEFAULT_OPTIONS,
normalizeEmbeddedMpvExtraOptions,
parseEmbeddedMpvExtraOptions,
resolveEmbeddedMpvSessionOptionArguments,
validateEmbeddedMpvExtraOptions,
} from './embedded-mpv-extra-options.util';
describe('embedded MPV extra options', () => {
it('parses one key=value pair per line and tolerates CRLF, spaces and a -- prefix', () => {
expect(
parseEmbeddedMpvExtraOptions(
'hwdec = auto-safe\r\n\n --cache-secs=30 \ndemuxer-lavf-o=reconnect=1,reconnect_delay_max=5'
)
).toEqual([
{ key: 'hwdec', value: 'auto-safe' },
{ key: 'cache-secs', value: '30' },
{
key: 'demuxer-lavf-o',
value: 'reconnect=1,reconnect_delay_max=5',
},
]);
});
it('skips lines without a value or with a malformed key', () => {
expect(
parseEmbeddedMpvExtraOptions(
'hwdec=\nno-border\n=auto\n bad key=1\nvolume=50'
)
).toEqual([{ key: 'volume', value: '50' }]);
});
it('normalizes to canonical lines but keeps malformed lines for the user to fix', () => {
expect(
normalizeEmbeddedMpvExtraOptions(
' --hwdec = auto \n\nnot an option\n'
)
).toBe('hwdec=auto\nnot an option');
expect(normalizeEmbeddedMpvExtraOptions(undefined)).toBe('');
expect(normalizeEmbeddedMpvExtraOptions(['a=1', '', 'b=2'])).toBe(
'a=1\nb=2'
);
});
it('reports malformed lines and forbidden keys separately', () => {
expect(validateEmbeddedMpvExtraOptions('')).toBeNull();
expect(validateEmbeddedMpvExtraOptions(undefined)).toBeNull();
expect(validateEmbeddedMpvExtraOptions('hwdec=auto')).toBeNull();
expect(
validateEmbeddedMpvExtraOptions(
'hwdec=auto\noops\nvo=null\nwid=1\nvo=gpu'
)
).toEqual({ invalidLines: ['oops'], forbiddenKeys: ['vo', 'wid'] });
expect(
validateEmbeddedMpvExtraOptions('input-ipc-server=/tmp/s')
).toEqual({
forbiddenKeys: ['input-ipc-server'],
});
});
it('prepends the network defaults and drops forbidden keys for the addon', () => {
expect(resolveEmbeddedMpvSessionOptionArguments('')).toEqual([
...EMBEDDED_MPV_NETWORK_DEFAULT_OPTIONS,
]);
expect(
resolveEmbeddedMpvSessionOptionArguments(
'wid=42\nhwdec=no\nnetwork-timeout=3\nbroken line'
)
).toEqual([
...EMBEDDED_MPV_NETWORK_DEFAULT_OPTIONS,
'hwdec=no',
'network-timeout=3',
]);
});
it('keeps the user override after the default it replaces', () => {
const options =
resolveEmbeddedMpvSessionOptionArguments('network-timeout=3');
expect(options.indexOf('network-timeout=10')).toBeLessThan(
options.indexOf('network-timeout=3')
);
});
});
@@ -0,0 +1,155 @@
import {
ExternalPlayerArgumentsInput,
parseExternalPlayerArguments,
} from './external-player-arguments.utils';
/**
* One libmpv option destined for an embedded MPV session, as entered in
* Settings > Playback: `key=value`, one per line, without the leading `--`.
*/
export interface EmbeddedMpvExtraOption {
key: string;
value: string;
}
/**
* Options the embed itself depends on. A user value for any of these would
* detach the video from the app window (`wid`, `vo`, `force-window`), break
* the Linux IPC control channel (`input-ipc-server`), or defeat the session
* lifecycle the addon relies on (`idle`, `keep-open`). `config`, `include`
* and `terminal` are refused because the embedded player deliberately runs
* with an isolated configuration and no terminal.
*/
export const EMBEDDED_MPV_FORBIDDEN_OPTION_KEYS: readonly string[] = [
'wid',
'vo',
'force-window',
'input-ipc-server',
'idle',
'keep-open',
'config',
'include',
'terminal',
];
/**
* Applied to every embedded session ahead of the user's options, on every
* engine: a stalled IPTV connection then surfaces as an mpv error within
* seconds instead of the 60 s libmpv default, and ffmpeg re-requests a
* dropped HTTP stream on its own before the app-level reconnect has to. A
* user line with the same key wins because it is applied later.
*/
export const EMBEDDED_MPV_NETWORK_DEFAULT_OPTIONS: readonly string[] = [
'network-timeout=10',
'demuxer-lavf-o=reconnect=1,reconnect_streamed=1,reconnect_delay_max=5',
];
export interface EmbeddedMpvExtraOptionsValidationErrors {
/** Lines that are not `key=value` with a non-empty value. */
invalidLines?: string[];
/** Keys from {@link EMBEDDED_MPV_FORBIDDEN_OPTION_KEYS} present in the text. */
forbiddenKeys?: string[];
}
const OPTION_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i;
interface ParsedOptionLine {
line: string;
option: EmbeddedMpvExtraOption | null;
}
function parseOptionLine(line: string): ParsedOptionLine {
const separator = line.indexOf('=');
if (separator === -1) {
return { line, option: null };
}
const key = line.slice(0, separator).trim().replace(/^--/, '');
const value = line.slice(separator + 1).trim();
if (!OPTION_KEY_PATTERN.test(key) || value.length === 0) {
return { line, option: null };
}
return { line, option: { key, value } };
}
function parseOptionLines(
value: ExternalPlayerArgumentsInput
): ParsedOptionLine[] {
return parseExternalPlayerArguments(value).map(parseOptionLine);
}
/** The well-formed `key=value` pairs in the text; malformed lines are skipped. */
export function parseEmbeddedMpvExtraOptions(
value: ExternalPlayerArgumentsInput
): EmbeddedMpvExtraOption[] {
return parseOptionLines(value)
.map((parsed) => parsed.option)
.filter((option): option is EmbeddedMpvExtraOption => option !== null);
}
/**
* Canonical text for storage: one trimmed line per option, `key=value`
* without the `--` prefix. Malformed lines are kept verbatim rather than
* dropped, so the user can still see and fix what the validator flagged.
*/
export function normalizeEmbeddedMpvExtraOptions(
value: ExternalPlayerArgumentsInput
): string {
return parseOptionLines(value)
.map((parsed) =>
parsed.option
? `${parsed.option.key}=${parsed.option.value}`
: parsed.line
)
.join('\n');
}
/**
* Validation for the Settings textarea: malformed lines and forbidden keys.
* Returns `null` when the text is acceptable, so it plugs into an Angular
* validator without this library depending on Angular.
*/
export function validateEmbeddedMpvExtraOptions(
value: unknown
): EmbeddedMpvExtraOptionsValidationErrors | null {
const parsedLines = parseOptionLines(
typeof value === 'string' ? value : undefined
);
const invalidLines = parsedLines
.filter((parsed) => parsed.option === null)
.map((parsed) => parsed.line);
const forbiddenKeys = [
...new Set(
parsedLines
.map((parsed) => parsed.option?.key)
.filter(
(key): key is string =>
key !== undefined &&
EMBEDDED_MPV_FORBIDDEN_OPTION_KEYS.includes(key)
)
),
];
if (invalidLines.length === 0 && forbiddenKeys.length === 0) {
return null;
}
return {
...(invalidLines.length > 0 ? { invalidLines } : {}),
...(forbiddenKeys.length > 0 ? { forbiddenKeys } : {}),
};
}
/**
* The `key=value` lines a session addon receives: the network defaults
* first, then the user's options with forbidden keys removed. Every engine
* (Windows/Linux/macOS native-view and the frame-copy helper) applies them
* in this order after its own built-in options.
*/
export function resolveEmbeddedMpvSessionOptionArguments(
value: ExternalPlayerArgumentsInput
): string[] {
const userOptions = parseEmbeddedMpvExtraOptions(value)
.filter(
(option) => !EMBEDDED_MPV_FORBIDDEN_OPTION_KEYS.includes(option.key)
)
.map((option) => `${option.key}=${option.value}`);
return [...EMBEDDED_MPV_NETWORK_DEFAULT_OPTIONS, ...userOptions];
}
@@ -2,13 +2,7 @@ import type { PlayerSubtitleStyle } from './subtitle-style.util';
import type { RecordingStartMetadata } from './recording-metadata.interface';
export type EmbeddedMpvSessionStatus =
| 'idle'
| 'loading'
| 'playing'
| 'paused'
| 'ended'
| 'error'
| 'closed';
'idle' | 'loading' | 'playing' | 'paused' | 'ended' | 'error' | 'closed';
export interface EmbeddedMpvBounds {
x: number;
@@ -129,4 +123,19 @@ export interface EmbeddedMpvSession {
startedAt: string;
updatedAt: string;
error?: string;
/**
* Present while the main process is waiting to reload a dropped stream
* or has such a reload in flight; absent once playback is back or the
* attempts are exhausted. Display only — the renderer never schedules.
*/
reconnect?: EmbeddedMpvReconnectInfo;
}
/** Progress of the main-process automatic reconnect for one session. */
export interface EmbeddedMpvReconnectInfo {
/** 1-based number of the attempt that is scheduled or in flight. */
attempt: number;
maxAttempts: number;
/** ISO timestamp at which the scheduled attempt fires. */
nextAttemptAt: string;
}
@@ -1,8 +1,5 @@
export type ExternalPlayerArgumentsInput =
| string
| readonly unknown[]
| null
| undefined;
string | readonly unknown[] | null | undefined;
export function parseExternalPlayerArguments(
value: ExternalPlayerArgumentsInput
@@ -212,6 +212,23 @@ export interface Settings {
* for its preload frame pump, which is fixed at window creation.
*/
embeddedMpvFrameCopy?: boolean;
/**
* Extra libmpv options for the embedded engine, one `key=value` per line
* (no leading `--`), applied on every engine after the built-in options
* and the network defaults, so a line here overrides them. Keys the
* embed depends on (`EMBEDDED_MPV_FORBIDDEN_OPTION_KEYS`) are refused by
* the form and skipped at session creation. Mirrored into the
* main-process config like `embeddedMpvFrameCopy`; a change applies to
* the next embedded session.
*/
embeddedMpvExtraOptions?: string;
/**
* Reload a dropped embedded MPV stream automatically (mpv error, or EOF
* on a live stream) with capped backoff. Default on; only a stream that
* already played is retried, so a dead URL stays on the manual Retry.
* Captured per session, so a change applies to the next session.
*/
embeddedMpvAutoReconnect?: boolean;
/** Cover/poster sizing preset applied across grids and rails */
coverSize?: CoverSize;
/** Live EPG panel layout: horizontal timeline (default) or vertical list */
@@ -355,3 +355,17 @@ describe('redactSensitiveData', () => {
});
});
});
describe('redactSensitiveData embedded MPV options', () => {
it('redacts the whole extra-options text, which can carry request headers', () => {
const secret = 'embedded-mpv-header-secret';
const redacted = redactSensitiveData({
player: 'embedded-mpv',
embeddedMpvExtraOptions: `cache-secs=5\nhttp-header-fields=X-Playback-Key: ${secret}`,
}) as Record<string, unknown>;
expect(JSON.stringify(redacted)).not.toContain(secret);
expect(redacted['player']).toBe('embedded-mpv');
});
});
@@ -15,6 +15,8 @@ const SENSITIVE_KEY_NAMES = new Set([
'credentials',
'deviceid',
'deviceid2',
// Free-form libmpv lines can carry request headers with credentials.
'embeddedmpvextraoptions',
'login',
'mac',
'macaddress',
@@ -33,11 +35,23 @@ const SENSITIVE_KEY_NAMES = new Set([
]);
const SENSITIVE_KEY_SUFFIXES = [
'apikey', 'authorization', 'cookie',
'deviceid', 'deviceid1', 'deviceid2',
'macaddress', 'passwd', 'password', 'prehash',
'serialnumber', 'signature', 'signature1', 'signature2',
'secret', 'token', 'username',
'apikey',
'authorization',
'cookie',
'deviceid',
'deviceid1',
'deviceid2',
'macaddress',
'passwd',
'password',
'prehash',
'serialnumber',
'signature',
'signature1',
'signature2',
'secret',
'token',
'username',
];
const XTREAM_CREDENTIAL_PATH_SEGMENTS = new Set([
@@ -160,25 +174,20 @@ function redactUrlStrings(
value: string,
sanitizeValue: (value: string) => string
): string {
return value.replace(
/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/giu,
(candidate) => {
try {
return redactUrl(new URL(candidate), sanitizeValue);
} catch {
return candidate;
}
return value.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/giu, (candidate) => {
try {
return redactUrl(new URL(candidate), sanitizeValue);
} catch {
return candidate;
}
);
});
}
function redactEmbeddedSensitivePairs(value: string): string {
const redactedAssignments = value.replace(
/\b([a-z][a-z0-9_.-]*)(\s*=\s*)((?:Bearer\s+)?[^&\s,;]+)/giu,
(match, key: string, separator: string) =>
isSensitiveKey(key)
? `${key}${separator}${REDACTED_VALUE}`
: match
isSensitiveKey(key) ? `${key}${separator}${REDACTED_VALUE}` : match
);
return redactedAssignments.replace(
/\b([a-z0-9_.-]*(?:api[-_.]?key|auth(?:orization)?|cookie|credentials|device[-_.]?id[12]?|login|mac(?:[-_.]?address)?|passwd|password|prehash|pwd|secret|serial[-_.]?number|set[-_.]?cookie|signature[12]?|sn|token|username))(\s*:\s*)(?:Bearer\s+)?[^;,\r\n]+/giu,
@@ -286,9 +295,10 @@ export function redactSensitiveData(
if (error.stack) {
const [, ...stackFrames] = error.stack.split('\n');
output['stack'] = visitString(
[`${output['name']}: ${output['message']}`, ...stackFrames].join(
'\n'
),
[
`${output['name']}: ${output['message']}`,
...stackFrames,
].join('\n'),
depth + 1
);
}
@@ -370,8 +380,7 @@ export function redactSensitiveData(
const stringKey = String(key);
const redactedKey = visitString(stringKey, depth + 1);
entries[redactedKey] =
isSensitiveKey(stringKey) &&
!/[/:?=&]/u.test(stringKey)
isSensitiveKey(stringKey) && !/[/:?=&]/u.test(stringKey)
? REDACTED_VALUE
: visit(entry, depth + 1);
}
@@ -127,6 +127,8 @@ function translations(prefix = ''): object {
EMBEDDED_MPV: {
PLAYER: {
PLAYBACK_FAILED: `${prefix}Playback failed`,
RECONNECTING: `${prefix}Reconnecting {{attempt}}/{{maxAttempts}}`,
STREAM_ENDED: `${prefix}Live stream ended`,
CHECKING_SUPPORT: `${prefix}Checking support`,
NOT_AVAILABLE: `${prefix}Not available`,
LOADING_STREAM: `${prefix}Loading stream`,
@@ -384,6 +386,30 @@ describe('EmbeddedMpvControlsAdapter', () => {
controller.session.set(session({ status: 'error', error: undefined }));
expect(adapter.state().statusMessage).toBe('Playback failed');
playback.set({ ...LIVE_PLAYBACK, isLive: true });
controller.session.set(session({ status: 'ended' }));
expect(adapter.state()).toMatchObject({
status: 'error',
statusMessage: 'Live stream ended',
});
playback.set({ ...VOD_PLAYBACK, isLive: false });
controller.session.set(
session({
status: 'error',
error: 'Decoder exploded',
reconnect: {
attempt: 3,
maxAttempts: 6,
nextAttemptAt: '2026-09-03T10:00:08Z',
},
})
);
expect(adapter.state()).toMatchObject({
status: 'loading',
statusMessage: 'Reconnecting 3/6',
});
});
it('delegates every non-recording shared-controls command', () => {
@@ -119,8 +119,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
volume: true,
audioTracks: true,
subtitles: optionalCapabilities?.subtitles ?? false,
externalSubtitles:
optionalCapabilities?.externalSubtitles ?? false,
externalSubtitles: optionalCapabilities?.externalSubtitles ?? false,
subtitleDelay: optionalCapabilities?.subtitleDelay ?? false,
subtitleStyle: optionalCapabilities?.subtitleStyle ?? false,
playbackSpeed: optionalCapabilities?.playbackSpeed ?? false,
@@ -307,6 +306,21 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
};
}
if (session?.reconnect) {
// A dropped stream the main process is reloading: a spinner with
// the attempt count reads better than the underlying mpv error.
return {
status: 'loading',
statusMessage: this.translate.instant(
'EMBEDDED_MPV.PLAYER.RECONNECTING',
{
attempt: session.reconnect.attempt,
maxAttempts: session.reconnect.maxAttempts,
}
),
};
}
if (!session || session.status === 'loading') {
return {
status: 'loading',
@@ -316,6 +330,12 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
};
}
const statusPlayback = this.configuredContext()?.playback();
const liveEnded =
session.status === 'ended' &&
statusPlayback !== undefined &&
this.isLivePlayback(statusPlayback);
switch (session.status) {
case 'playing':
case 'paused':
@@ -323,6 +343,16 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
return { status: session.status, statusMessage: '' };
case 'ended':
case 'closed':
if (liveEnded) {
// A broadcast never ends on its own: surface it like an
// error so the shared controls offer a way back.
return {
status: 'error',
statusMessage: this.translate.instant(
'EMBEDDED_MPV.PLAYER.STREAM_ENDED'
),
};
}
return { status: 'ended', statusMessage: '' };
case 'error':
return {
@@ -31,13 +31,16 @@
data-embedded-mpv-frame
></canvas>
}
@if (isErrored()) {
@if (isErrored() || isReconnecting() || isLiveEnded()) {
<div
class="embedded-mpv-player__stalled"
role="alert"
aria-live="assertive"
[attr.data-reconnecting]="isReconnecting() ? '' : null"
>
<mat-icon>error_outline</mat-icon>
<mat-icon>{{
isReconnecting() ? 'sync' : 'error_outline'
}}</mat-icon>
<p>{{ statusLabel() }}</p>
<button mat-stroked-button type="button" (click)="retry()">
<mat-icon>refresh</mat-icon>
@@ -215,12 +218,10 @@
[disabled]="!canSeek()"
(click)="seekBy(10)"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS'
| translate
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS' | translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS'
| translate
'EMBEDDED_MPV.PLAYER.FORWARD_10_SECONDS' | translate
"
matTooltipPosition="above"
>
@@ -330,9 +331,7 @@
>
<input
class="embedded-mpv-player__slider embedded-mpv-player__slider--volume"
[style.--slider-progress.%]="
volume() * 100
"
[style.--slider-progress.%]="volume() * 100"
type="range"
min="0"
max="1"
@@ -367,4 +367,67 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
).toBeNull();
expect(fixture.nativeElement.textContent).toContain('--:--');
});
it('offers Retry when a live stream ends without a reconnect in progress', () => {
fixture.componentInstance.playback = {
...fixture.componentInstance.playback,
contentInfo: undefined,
isLive: true,
};
fixture.detectChanges();
controller.session.update((session) =>
session ? { ...session, status: 'ended' } : session
);
fixture.detectChanges();
const overlay = fixture.debugElement.query(
By.css('.embedded-mpv-player__stalled')
);
expect(overlay).not.toBeNull();
expect(overlay.nativeElement.textContent).toContain(
'EMBEDDED_MPV.PLAYER.STREAM_ENDED'
);
expect(overlay.query(By.css('button'))).not.toBeNull();
});
it('shows the reconnect attempt instead of the mpv error while the main process retries', () => {
controller.session.update((session) =>
session
? {
...session,
status: 'error',
error: 'Connection reset by peer',
reconnect: {
attempt: 2,
maxAttempts: 6,
nextAttemptAt: '2026-06-06T12:00:04Z',
},
}
: session
);
fixture.detectChanges();
const overlay = fixture.debugElement.query(
By.css('.embedded-mpv-player__stalled[data-reconnecting]')
);
expect(overlay).not.toBeNull();
expect(overlay.nativeElement.textContent).toContain(
'EMBEDDED_MPV.PLAYER.RECONNECTING'
);
expect(overlay.nativeElement.textContent).not.toContain(
'Connection reset by peer'
);
expect(overlay.query(By.css('button'))).not.toBeNull();
controller.session.update((session) =>
session
? { ...session, status: 'playing', reconnect: undefined }
: session
);
fixture.detectChanges();
expect(
fixture.debugElement.query(By.css('.embedded-mpv-player__stalled'))
).toBeNull();
});
});
@@ -173,6 +173,13 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
);
readonly isPlaying = computed(() => this.session()?.status === 'playing');
readonly isErrored = computed(() => this.session()?.status === 'error');
/**
* The main process is waiting to reload a dropped stream or has the
* reload in flight (see embedded-mpv-reconnect.ts); shown like an error
* with a countdown-free "attempt N of M" line, Retry still available.
*/
readonly reconnectInfo = computed(() => this.session()?.reconnect ?? null);
readonly isReconnecting = computed(() => this.reconnectInfo() !== null);
readonly isLivePlayback = computed(() => {
const playback = this.playback();
if (typeof playback.isLive === 'boolean') {
@@ -181,6 +188,14 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
return !playback.contentInfo;
});
/**
* A live stream that ended and is not being reconnected: a broadcast
* never ends on its own, so this is a loss the viewer must be able to
* act on — the `ended` state otherwise looks like a paused player.
*/
readonly isLiveEnded = computed(
() => this.isLivePlayback() && this.session()?.status === 'ended'
);
readonly canSeek = computed(
() =>
!this.isLivePlayback() && (this.session()?.durationSeconds ?? 0) > 0
@@ -194,12 +209,21 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly statusLabel = computed(() => {
this.translationsTick();
const session = this.session();
if (session?.reconnect) {
return this.translate.instant('EMBEDDED_MPV.PLAYER.RECONNECTING', {
attempt: session.reconnect.attempt,
maxAttempts: session.reconnect.maxAttempts,
});
}
if (session?.status === 'error') {
return (
session.error ??
this.translate.instant('EMBEDDED_MPV.PLAYER.PLAYBACK_FAILED')
);
}
if (session?.status === 'ended' && this.isLivePlayback()) {
return this.translate.instant('EMBEDDED_MPV.PLAYER.STREAM_ENDED');
}
if (!this.support()) {
return this.translate.instant(
'EMBEDDED_MPV.PLAYER.CHECKING_SUPPORT'