mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user