feat(playback): advanced subtitle support in shared player controls (#1471)

This commit is contained in:
4gray authored and GitHub committed 2026-08-23 13:57:46 +02:00
1 parent d6a9c23148
commit 069b8b3cc9
75 files changed
+3337 -49

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
---
type: feature
area: playback
issues: [1408]
---
The shared player controls' subtitle menu now loads external subtitle files
(.srt/.vtt in the built-in web players, plus .ass in Embedded MPV), adjusts the
subtitle timing offset in ±0.5 s steps, and sets the subtitle text size and
color — size/color persist across sessions and are shared between engines.
+16
View File
@@ -177,6 +177,22 @@ Key files:
engine-neutral `PlayerController` contract, standalone
`app-player-controls`, generic web-video adapter/helper, and component-scoped
`WEB_PLAYER_SHARED_CONTROLS` rollout token.
- The subtitle menu carries capability-gated advanced subtitle support
(#1408): external subtitle file loading, a ±0.5 s timing-offset row, and
size/color styling persisted in the shared `subtitleStyle` localStorage key.
HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt`
via a DOM file picker with encoding detection, native `TextTrack` rendering,
`::cue` styling, delay only while the loaded file is the selected track;
picks are source-generation-guarded and engine deselection precedes external
track activation). The canonical style shape and clamp/normalize rules are
shared with the main process via `@iptvnator/shared/interfaces`
(`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through
helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`,
main-process file dialog, ASS supported, delay for all tracks). Video.js
shared mode, vendor-chrome paths, native-view, and the Linux out-of-process
path advertise no such capability and render no UI. Contract details:
`docs/architecture/player-controls-contract.md` ("Advanced subtitle
support").
- In fullscreen, `app-player-controls` shows a pointer-transparent media-title
overlay at the top while controls are revealed (`mediaTitle` input:
movie/channel/series name, plus an `S01E03` second line for episodes). Series
+1 -1
View File
@@ -1103,7 +1103,7 @@ engine` (restart required) or
helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime
contracts: `docs/architecture/embedded-mpv-native.md` and
`tools/embedded-mpv/README.md`.
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so ranked recovery actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation — but the playback keyboard shortcuts (Space/K, F, arrow seek/volume, M) still work: each vendor-chrome player attaches `LegacyPlayerShortcuts` (a wrapper over the same `ControlsShortcuts` arbitration/ignore rules) with engine-specific coLine truncated
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a desLine truncated
- Shared web picture-in-picture stays inside that default-off rollout.
`PlayerController` exposes capability `pictureInPicture`, state
`pictureInPictureActive`/`canPictureInPicture`, and command
@@ -5,6 +5,7 @@ import {
test,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import { writeFileSync } from 'fs';
import { join } from 'path';
import {
assertNativeFallbackPrerequisites,
@@ -197,6 +198,58 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
)
.toBe('playing');
// Advanced subtitle support (#1408): exercise the real preload
// IPC, main-process validation, and the helper's `sub-add`
// protocol command against the packaged runtime. The native file
// dialog cannot be automated, so the path-based IPC is driven
// directly with a fixture file; mpv parses and auto-selects it,
// and the helper's track-list observation must report it back.
const subtitlePath = join(dataDir, 'packaged-smoke.srt');
writeFileSync(
subtitlePath,
'1\n00:00:00,000 --> 00:00:10,000\nPackaged subtitle smoke\n'
);
const withSubtitle = await launchedFrameCopyApp.mainWindow.evaluate(
async ({ sessionId, path }) =>
window.electron.addEmbeddedMpvSubtitle?.(sessionId, path),
{ sessionId: created.id, path: subtitlePath }
);
expect(withSubtitle?.id).toBe(created.id);
await expect
.poll(
async () => {
const session = await getLatestSession(
launchedFrameCopyApp,
created.id
);
return session?.subtitleTracks?.length ?? 0;
},
{ timeout: 15000 }
)
.toBeGreaterThan(0);
// Delay and style ride the same session-guarded IPC; mpv does not
// echo the values in the snapshot, so a session reply for the live
// session (instead of a rejection) is the observable contract.
const delayReply = await launchedFrameCopyApp.mainWindow.evaluate(
(sessionId) =>
window.electron.setEmbeddedMpvSubtitleDelay?.(
sessionId,
1.5
),
created.id
);
expect(delayReply?.id).toBe(created.id);
const styleReply = await launchedFrameCopyApp.mainWindow.evaluate(
(sessionId) =>
window.electron.setEmbeddedMpvSubtitleStyle?.(sessionId, {
sizePercent: 150,
color: '#ffe94f',
}),
created.id
);
expect(styleReply?.id).toBe(created.id);
await launchedFrameCopyApp.mainWindow.evaluate(
(sessionId) =>
window.electron.setEmbeddedMpvPaused(sessionId, true),
@@ -569,6 +569,25 @@ void handleCommand(const Command& command) {
const std::string value = command.get("value");
setPropertyString(command.name.c_str(),
value == "-1" ? "no" : value);
} else if (command.name == "sub-add") {
/* External subtitle file (.srt/.ass/.vtt/…): mpv parses and renders
* it natively; "select" makes it the active subtitle track. */
const std::string path = command.get("path");
if (!path.empty()) {
const char* args[] = {"sub-add", path.c_str(), "select", nullptr};
mpv_command(g_state.mpv, args);
}
} else if (command.name == "sub-delay") {
double delay =
std::clamp(command.getDouble("value", 0), -60.0, 60.0);
mpv_set_property(g_state.mpv, "sub-delay", MPV_FORMAT_DOUBLE, &delay);
} else if (command.name == "sub-scale") {
double scale = std::clamp(command.getDouble("value", 1), 0.25, 4.0);
mpv_set_property(g_state.mpv, "sub-scale", MPV_FORMAT_DOUBLE, &scale);
} else if (command.name == "sub-color") {
/* Affects text subtitles rendered by mpv's OSD path; ASS files keep
* their own embedded styling. */
setPropertyString("sub-color", command.get("value", "#FFFFFF"));
} else if (command.name == "speed") {
double speed = std::clamp(command.getDouble("value", 1), 0.25, 4.0);
mpv_set_property(g_state.mpv, "speed", MPV_FORMAT_DOUBLE, &speed);
@@ -28,6 +28,7 @@ import type {
DownloadMetadataSnapshot,
EmbeddedMpvBounds,
EmbeddedMpvRecordingStartOptions,
EmbeddedMpvSubtitleStyle,
EmbeddedMpvSession,
EmbeddedMpvSupport,
ElectronBridgeApi,
@@ -587,6 +588,31 @@ const electronApi: ElectronBridgeApi = {
sessionId,
trackId
),
addEmbeddedMpvSubtitle: (
sessionId: string,
filePath: string
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_ADD_SUBTITLE', sessionId, filePath),
setEmbeddedMpvSubtitleDelay: (
sessionId: string,
seconds: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke(
'EMBEDDED_MPV_SET_SUBTITLE_DELAY',
sessionId,
seconds
),
setEmbeddedMpvSubtitleStyle: (
sessionId: string,
style: EmbeddedMpvSubtitleStyle
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke(
'EMBEDDED_MPV_SET_SUBTITLE_STYLE',
sessionId,
style
),
selectEmbeddedMpvSubtitleFile: (): Promise<string | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SELECT_SUBTITLE_FILE'),
setEmbeddedMpvSpeed: (
sessionId: string,
speed: number
@@ -1,18 +1,23 @@
import { ipcMain } from 'electron';
import {
EMBEDDED_MPV_ADD_SUBTITLE,
EMBEDDED_MPV_CREATE_SESSION,
EMBEDDED_MPV_DISPOSE_SESSION,
EMBEDDED_MPV_GET_FRAME_SOURCE,
EMBEDDED_MPV_LOAD_PLAYBACK,
EMBEDDED_MPV_PREPARE,
EMBEDDED_MPV_SEEK,
EMBEDDED_MPV_SELECT_SUBTITLE_FILE,
EMBEDDED_MPV_SET_ASPECT,
EMBEDDED_MPV_SET_AUDIO_TRACK,
EMBEDDED_MPV_SET_BOUNDS,
EMBEDDED_MPV_SET_PAUSED,
EMBEDDED_MPV_SET_SPEED,
EMBEDDED_MPV_SET_SUBTITLE_DELAY,
EMBEDDED_MPV_SET_SUBTITLE_STYLE,
EMBEDDED_MPV_SET_SUBTITLE_TRACK,
EMBEDDED_MPV_SET_VOLUME,
EmbeddedMpvSubtitleStyle,
EMBEDDED_MPV_GET_DEFAULT_RECORDING_FOLDER,
EMBEDDED_MPV_SELECT_RECORDING_FOLDER,
EMBEDDED_MPV_START_RECORDING,
@@ -110,6 +115,28 @@ handleEmbeddedMpv(
getService().setSubtitleTrack(sessionId, trackId)
);
handleEmbeddedMpv(
EMBEDDED_MPV_ADD_SUBTITLE,
(sessionId: string, filePath: string) =>
getService().addSubtitle(sessionId, filePath)
);
handleEmbeddedMpv(
EMBEDDED_MPV_SET_SUBTITLE_DELAY,
(sessionId: string, seconds: number) =>
getService().setSubtitleDelay(sessionId, seconds)
);
handleEmbeddedMpv(
EMBEDDED_MPV_SET_SUBTITLE_STYLE,
(sessionId: string, style: EmbeddedMpvSubtitleStyle) =>
getService().setSubtitleStyle(sessionId, style)
);
handleEmbeddedMpv(EMBEDDED_MPV_SELECT_SUBTITLE_FILE, () =>
getService().selectSubtitleFile()
);
handleEmbeddedMpv(
EMBEDDED_MPV_SET_SPEED,
(sessionId: string, speed: number) => getService().setSpeed(sessionId, speed)
@@ -110,6 +110,42 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
expect(line).toContain('opt.http-header-fields=X-Token: abc');
});
it('sends the subtitle protocol commands over stdin', () => {
const sessionId = createSession();
adapter.addSubtitle(sessionId, '/subs/movie subs.srt');
expect(child.stdin.written.at(-1)).toBe(
'sub-add\tpath=/subs/movie subs.srt\n'
);
adapter.setSubtitleDelay(sessionId, 1.5);
expect(child.stdin.written.at(-1)).toBe('sub-delay\tvalue=1.5\n');
adapter.setSubtitleStyle(sessionId, {
sizePercent: 150,
color: '#ffe94f',
});
expect(child.stdin.written.slice(-2)).toEqual([
'sub-scale\tvalue=1.5\n',
'sub-color\tvalue=#ffe94f\n',
]);
// A null color resets mpv's default so a previous pick cannot linger.
adapter.setSubtitleStyle(sessionId, { sizePercent: 100, color: null });
expect(child.stdin.written.slice(-2)).toEqual([
'sub-scale\tvalue=1\n',
'sub-color\tvalue=#FFFFFF\n',
]);
});
it('percent-escapes protocol-reserved characters in subtitle paths', () => {
const sessionId = createSession();
adapter.addSubtitle(sessionId, '/subs/tab\tname.srt');
expect(child.stdin.written.at(-1)).toBe(
'sub-add\tpath=/subs/tab%09name.srt\n'
);
});
it('scales bounds and ignores hidden/degenerate bounds', () => {
const sessionId = createSession();
adapter.setBounds(sessionId, { x: 0, y: 0, width: 800, height: 450 });
@@ -192,6 +192,30 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
this.send(sessionId, `sid\tvalue=${trackId}`);
}
addSubtitle(sessionId: string, filePath: string): void {
this.send(
sessionId,
`sub-add\tpath=${encodeProtocolValue(filePath)}`
);
}
setSubtitleDelay(sessionId: string, seconds: number): void {
this.send(sessionId, `sub-delay\tvalue=${seconds}`);
}
setSubtitleStyle(
sessionId: string,
style: { sizePercent: number; color: string | null }
): void {
this.send(sessionId, `sub-scale\tvalue=${style.sizePercent / 100}`);
// mpv's default sub-color; an explicit reset keeps a previous pick
// from lingering after the user returns to "default".
this.send(
sessionId,
`sub-color\tvalue=${encodeProtocolValue(style.color ?? '#FFFFFF')}`
);
}
setSpeed(sessionId: string, speed: number): void {
this.send(sessionId, `speed\tvalue=${speed}`);
}
@@ -19,6 +19,7 @@ import {
EmbeddedMpvRecordingState,
EmbeddedMpvSession,
EmbeddedMpvSessionStatus,
EmbeddedMpvSubtitleStyle,
EmbeddedMpvSubtitleTrack,
EmbeddedMpvEngine,
EmbeddedMpvFrameSource,
@@ -26,6 +27,8 @@ import {
EMBEDDED_MPV_FRAME_SOURCE_CHANGED,
EMBEDDED_MPV_SESSION_UPDATE,
ResolvedPortalPlayback,
clampSubtitleDelay,
normalizeSubtitleStyle,
} from '@iptvnator/shared/interfaces';
import { toNativeViewBounds } from './embedded-mpv-bounds.util';
import { embeddedMpvRecordingTracker } from './embedded-mpv-recording-tracker';
@@ -75,6 +78,9 @@ export interface NativeEmbeddedMpvAddon {
setVolume(sessionId: string, volume: number): void;
setAudioTrack(sessionId: string, trackId: number): void;
setSubtitleTrack?(sessionId: string, trackId: number): void;
addSubtitle?(sessionId: string, filePath: string): void;
setSubtitleDelay?(sessionId: string, seconds: number): void;
setSubtitleStyle?(sessionId: string, style: EmbeddedMpvSubtitleStyle): void;
setSpeed?(sessionId: string, speed: number): void;
setAspect?(sessionId: string, aspect: string): void;
startRecording?(sessionId: string, targetPath: string): void;
@@ -241,6 +247,9 @@ export class EmbeddedMpvNativeService {
aspectOverride: true,
screenshot: false,
recording: true,
externalSubtitles: true,
subtitleDelay: true,
subtitleStyle: true,
};
}
const addon = this.addon;
@@ -252,6 +261,9 @@ export class EmbeddedMpvNativeService {
recording:
typeof addon?.startRecording === 'function' &&
typeof addon?.stopRecording === 'function',
externalSubtitles: typeof addon?.addSubtitle === 'function',
subtitleDelay: typeof addon?.setSubtitleDelay === 'function',
subtitleStyle: typeof addon?.setSubtitleStyle === 'function',
};
}
@@ -562,6 +574,74 @@ export class EmbeddedMpvNativeService {
return this.refreshSession(sessionId);
}
addSubtitle(sessionId: string, filePath: string): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (typeof addon.addSubtitle !== 'function') {
throw new Error(
'Embedded MPV addon does not support external subtitles. Rebuild the native addon to enable this feature.'
);
}
const normalized = typeof filePath === 'string' ? filePath.trim() : '';
if (!normalized || !existsSync(normalized)) {
throw new Error('The selected subtitle file was not found.');
}
addon.addSubtitle(sessionId, normalized);
return this.refreshSession(sessionId);
}
setSubtitleDelay(
sessionId: string,
seconds: number
): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (typeof addon.setSubtitleDelay !== 'function') {
throw new Error(
'Embedded MPV addon does not support subtitle delay. Rebuild the native addon to enable this feature.'
);
}
// Same rules as the renderer, same implementation: the shared helper
// is the one place the limits are defined.
addon.setSubtitleDelay(sessionId, clampSubtitleDelay(seconds));
return this.refreshSession(sessionId);
}
setSubtitleStyle(
sessionId: string,
style: EmbeddedMpvSubtitleStyle
): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (typeof addon.setSubtitleStyle !== 'function') {
throw new Error(
'Embedded MPV addon does not support subtitle styling. Rebuild the native addon to enable this feature.'
);
}
// Re-validate untrusted IPC input with the exact renderer rules.
addon.setSubtitleStyle(sessionId, normalizeSubtitleStyle(style));
return this.refreshSession(sessionId);
}
async selectSubtitleFile(): Promise<string | null> {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
title: 'Select Subtitle File',
filters: [
{
name: 'Subtitle files',
extensions: ['srt', 'ass', 'ssa', 'vtt', 'sub'],
},
],
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
return result.filePaths[0];
}
setSpeed(sessionId: string, speed: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "تشغيل صورة داخل صورة",
"EXIT_PICTURE_IN_PICTURE": "إنهاء صورة داخل صورة",
"ENTER_FULLSCREEN": "ملء الشاشة",
"EXIT_FULLSCREEN": "الخروج من ملء الشاشة"
"EXIT_FULLSCREEN": "الخروج من ملء الشاشة",
"LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…",
"SUBTITLE_DELAY": "تأخير الترجمة",
"SUBTITLE_DELAY_DECREASE": "عرض الترجمة مبكرًا",
"SUBTITLE_DELAY_INCREASE": "عرض الترجمة لاحقًا",
"SUBTITLE_DELAY_RESET": "إعادة ضبط تأخير الترجمة",
"SUBTITLE_SIZE": "حجم الترجمة",
"SUBTITLE_COLOR": "لون الترجمة",
"SUBTITLE_COLOR_DEFAULT": "افتراضي",
"SUBTITLE_COLOR_WHITE": "أبيض",
"SUBTITLE_COLOR_YELLOW": "أصفر",
"SUBTITLE_COLOR_CYAN": "سماوي"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "شغّل صورة داخل صورة",
"EXIT_PICTURE_IN_PICTURE": "خرج من صورة داخل صورة",
"ENTER_FULLSCREEN": "دخل لملء الشاشة",
"EXIT_FULLSCREEN": "خرج من ملء الشاشة"
"EXIT_FULLSCREEN": "خرج من ملء الشاشة",
"LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…",
"SUBTITLE_DELAY": "تأخير الترجمة",
"SUBTITLE_DELAY_DECREASE": "ورّي الترجمة قبل",
"SUBTITLE_DELAY_INCREASE": "ورّي الترجمة من بعد",
"SUBTITLE_DELAY_RESET": "رجّع تأخير الترجمة",
"SUBTITLE_SIZE": "حجم الترجمة",
"SUBTITLE_COLOR": "لون الترجمة",
"SUBTITLE_COLOR_DEFAULT": "افتراضي",
"SUBTITLE_COLOR_WHITE": "أبيض",
"SUBTITLE_COLOR_YELLOW": "أصفر",
"SUBTITLE_COLOR_CYAN": "سماوي"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "Уключыць рэжым «выява ў выяве»",
"EXIT_PICTURE_IN_PICTURE": "Выйсці з рэжыму «выява ў выяве»",
"ENTER_FULLSCREEN": "Увайсці ў поўнаэкранны рэжым",
"EXIT_FULLSCREEN": "Выйсці з поўнаэкраннага рэжыму"
"EXIT_FULLSCREEN": "Выйсці з поўнаэкраннага рэжыму",
"LOAD_SUBTITLE_FILE": "Загрузіць файл субтытраў…",
"SUBTITLE_DELAY": "Затрымка субтытраў",
"SUBTITLE_DELAY_DECREASE": "Паказваць субтытры раней",
"SUBTITLE_DELAY_INCREASE": "Паказваць субтытры пазней",
"SUBTITLE_DELAY_RESET": "Скінуць затрымку субтытраў",
"SUBTITLE_SIZE": "Памер субтытраў",
"SUBTITLE_COLOR": "Колер субтытраў",
"SUBTITLE_COLOR_DEFAULT": "Па змаўчанні",
"SUBTITLE_COLOR_WHITE": "Белы",
"SUBTITLE_COLOR_YELLOW": "Жоўты",
"SUBTITLE_COLOR_CYAN": "Блакітны"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "Bild-in-Bild starten",
"EXIT_PICTURE_IN_PICTURE": "Bild-in-Bild beenden",
"ENTER_FULLSCREEN": "Vollbild aktivieren",
"EXIT_FULLSCREEN": "Vollbild beenden"
"EXIT_FULLSCREEN": "Vollbild beenden",
"LOAD_SUBTITLE_FILE": "Untertiteldatei laden…",
"SUBTITLE_DELAY": "Untertitelverzögerung",
"SUBTITLE_DELAY_DECREASE": "Untertitel früher anzeigen",
"SUBTITLE_DELAY_INCREASE": "Untertitel später anzeigen",
"SUBTITLE_DELAY_RESET": "Untertitelverzögerung zurücksetzen",
"SUBTITLE_SIZE": "Untertitelgröße",
"SUBTITLE_COLOR": "Untertitelfarbe",
"SUBTITLE_COLOR_DEFAULT": "Standard",
"SUBTITLE_COLOR_WHITE": "Weiß",
"SUBTITLE_COLOR_YELLOW": "Gelb",
"SUBTITLE_COLOR_CYAN": "Cyan"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "Έναρξη εικόνας εντός εικόνας",
"EXIT_PICTURE_IN_PICTURE": "Έξοδος από εικόνα εντός εικόνας",
"ENTER_FULLSCREEN": "Μετάβαση σε πλήρη οθόνη",
"EXIT_FULLSCREEN": "Έξοδος από πλήρη οθόνη"
"EXIT_FULLSCREEN": "Έξοδος από πλήρη οθόνη",
"LOAD_SUBTITLE_FILE": "Φόρτωση αρχείου υποτίτλων…",
"SUBTITLE_DELAY": "Καθυστέρηση υποτίτλων",
"SUBTITLE_DELAY_DECREASE": "Εμφάνιση υποτίτλων νωρίτερα",
"SUBTITLE_DELAY_INCREASE": "Εμφάνιση υποτίτλων αργότερα",
"SUBTITLE_DELAY_RESET": "Επαναφορά καθυστέρησης υποτίτλων",
"SUBTITLE_SIZE": "Μέγεθος υποτίτλων",
"SUBTITLE_COLOR": "Χρώμα υποτίτλων",
"SUBTITLE_COLOR_DEFAULT": "Προεπιλογή",
"SUBTITLE_COLOR_WHITE": "Λευκό",
"SUBTITLE_COLOR_YELLOW": "Κίτρινο",
"SUBTITLE_COLOR_CYAN": "Κυανό"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "Enter picture-in-picture",
"EXIT_PICTURE_IN_PICTURE": "Exit picture-in-picture",
"ENTER_FULLSCREEN": "Enter fullscreen",
"EXIT_FULLSCREEN": "Exit fullscreen"
"EXIT_FULLSCREEN": "Exit fullscreen",
"LOAD_SUBTITLE_FILE": "Load subtitle file…",
"SUBTITLE_DELAY": "Subtitle delay",
"SUBTITLE_DELAY_DECREASE": "Show subtitles earlier",
"SUBTITLE_DELAY_INCREASE": "Show subtitles later",
"SUBTITLE_DELAY_RESET": "Reset subtitle delay",
"SUBTITLE_SIZE": "Subtitle size",
"SUBTITLE_COLOR": "Subtitle color",
"SUBTITLE_COLOR_DEFAULT": "Default",
"SUBTITLE_COLOR_WHITE": "White",
"SUBTITLE_COLOR_YELLOW": "Yellow",
"SUBTITLE_COLOR_CYAN": "Cyan"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "Activar imagen en imagen",
"EXIT_PICTURE_IN_PICTURE": "Salir de imagen en imagen",
"ENTER_FULLSCREEN": "Entrar en pantalla completa",
"EXIT_FULLSCREEN": "Salir de pantalla completa"
"EXIT_FULLSCREEN": "Salir de pantalla completa",
"LOAD_SUBTITLE_FILE": "Cargar archivo de subtítulos…",
"SUBTITLE_DELAY": "Retardo de subtítulos",
"SUBTITLE_DELAY_DECREASE": "Mostrar subtítulos antes",
"SUBTITLE_DELAY_INCREASE": "Mostrar subtítulos después",
"SUBTITLE_DELAY_RESET": "Restablecer retardo de subtítulos",
"SUBTITLE_SIZE": "Tamaño de subtítulos",
"SUBTITLE_COLOR": "Color de subtítulos",
"SUBTITLE_COLOR_DEFAULT": "Predeterminado",
"SUBTITLE_COLOR_WHITE": "Blanco",
"SUBTITLE_COLOR_YELLOW": "Amarillo",
"SUBTITLE_COLOR_CYAN": "Cian"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "Activer le mode image dans l’image",
"EXIT_PICTURE_IN_PICTURE": "Quitter le mode image dans l’image",
"ENTER_FULLSCREEN": "Passer en plein écran",
"EXIT_FULLSCREEN": "Quitter le plein écran"
"EXIT_FULLSCREEN": "Quitter le plein écran",
"LOAD_SUBTITLE_FILE": "Charger un fichier de sous-titres…",
"SUBTITLE_DELAY": "Décalage des sous-titres",
"SUBTITLE_DELAY_DECREASE": "Afficher les sous-titres plus tôt",
"SUBTITLE_DELAY_INCREASE": "Afficher les sous-titres plus tard",
"SUBTITLE_DELAY_RESET": "Réinitialiser le décalage des sous-titres",
"SUBTITLE_SIZE": "Taille des sous-titres",
"SUBTITLE_COLOR": "Couleur des sous-titres",
"SUBTITLE_COLOR_DEFAULT": "Par défaut",
"SUBTITLE_COLOR_WHITE": "Blanc",
"SUBTITLE_COLOR_YELLOW": "Jaune",
"SUBTITLE_COLOR_CYAN": "Cyan"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "Kép a képben mód bekapcsolása",
"EXIT_PICTURE_IN_PICTURE": "Kilépés a kép a képben módból",
"ENTER_FULLSCREEN": "Váltás teljes képernyőre",
"EXIT_FULLSCREEN": "Kilépés a teljes képernyős módból"
"EXIT_FULLSCREEN": "Kilépés a teljes képernyős módból",
"LOAD_SUBTITLE_FILE": "Feliratfájl betöltése…",
"SUBTITLE_DELAY": "Felirat késleltetése",
"SUBTITLE_DELAY_DECREASE": "Feliratok megjelenítése korábban",
"SUBTITLE_DELAY_INCREASE": "Feliratok megjelenítése később",
"SUBTITLE_DELAY_RESET": "Feliratkésleltetés visszaállítása",
"SUBTITLE_SIZE": "Felirat mérete",
"SUBTITLE_COLOR": "Felirat színe",
"SUBTITLE_COLOR_DEFAULT": "Alapértelmezett",
"SUBTITLE_COLOR_WHITE": "Fehér",
"SUBTITLE_COLOR_YELLOW": "Sárga",
"SUBTITLE_COLOR_CYAN": "Cián"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "Attiva Picture-in-Picture",
"EXIT_PICTURE_IN_PICTURE": "Esci da Picture-in-Picture",
"ENTER_FULLSCREEN": "Attiva schermo intero",
"EXIT_FULLSCREEN": "Esci dallo schermo intero"
"EXIT_FULLSCREEN": "Esci dallo schermo intero",
"LOAD_SUBTITLE_FILE": "Carica file sottotitoli…",
"SUBTITLE_DELAY": "Ritardo sottotitoli",
"SUBTITLE_DELAY_DECREASE": "Mostra i sottotitoli prima",
"SUBTITLE_DELAY_INCREASE": "Mostra i sottotitoli dopo",
"SUBTITLE_DELAY_RESET": "Reimposta il ritardo dei sottotitoli",
"SUBTITLE_SIZE": "Dimensione sottotitoli",
"SUBTITLE_COLOR": "Colore sottotitoli",
"SUBTITLE_COLOR_DEFAULT": "Predefinito",
"SUBTITLE_COLOR_WHITE": "Bianco",
"SUBTITLE_COLOR_YELLOW": "Giallo",
"SUBTITLE_COLOR_CYAN": "Ciano"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "ピクチャーインピクチャーを開始",
"EXIT_PICTURE_IN_PICTURE": "ピクチャーインピクチャーを終了",
"ENTER_FULLSCREEN": "全画面表示",
"EXIT_FULLSCREEN": "全画面表示を終了"
"EXIT_FULLSCREEN": "全画面表示を終了",
"LOAD_SUBTITLE_FILE": "字幕ファイルを読み込む…",
"SUBTITLE_DELAY": "字幕の遅延",
"SUBTITLE_DELAY_DECREASE": "字幕を早く表示",
"SUBTITLE_DELAY_INCREASE": "字幕を遅く表示",
"SUBTITLE_DELAY_RESET": "字幕の遅延をリセット",
"SUBTITLE_SIZE": "字幕サイズ",
"SUBTITLE_COLOR": "字幕の色",
"SUBTITLE_COLOR_DEFAULT": "デフォルト",
"SUBTITLE_COLOR_WHITE": "白",
"SUBTITLE_COLOR_YELLOW": "黄色",
"SUBTITLE_COLOR_CYAN": "シアン"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "PIP 모드 시작",
"EXIT_PICTURE_IN_PICTURE": "PIP 모드 종료",
"ENTER_FULLSCREEN": "전체 화면",
"EXIT_FULLSCREEN": "전체 화면 종료"
"EXIT_FULLSCREEN": "전체 화면 종료",
"LOAD_SUBTITLE_FILE": "자막 파일 불러오기…",
"SUBTITLE_DELAY": "자막 지연",
"SUBTITLE_DELAY_DECREASE": "자막을 더 빨리 표시",
"SUBTITLE_DELAY_INCREASE": "자막을 더 늦게 표시",
"SUBTITLE_DELAY_RESET": "자막 지연 초기화",
"SUBTITLE_SIZE": "자막 크기",
"SUBTITLE_COLOR": "자막 색상",
"SUBTITLE_COLOR_DEFAULT": "기본값",
"SUBTITLE_COLOR_WHITE": "흰색",
"SUBTITLE_COLOR_YELLOW": "노란색",
"SUBTITLE_COLOR_CYAN": "청록색"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "Beeld-in-beeld starten",
"EXIT_PICTURE_IN_PICTURE": "Beeld-in-beeld sluiten",
"ENTER_FULLSCREEN": "Volledig scherm openen",
"EXIT_FULLSCREEN": "Volledig scherm sluiten"
"EXIT_FULLSCREEN": "Volledig scherm sluiten",
"LOAD_SUBTITLE_FILE": "Ondertitelbestand laden…",
"SUBTITLE_DELAY": "Ondertitelvertraging",
"SUBTITLE_DELAY_DECREASE": "Ondertitels eerder tonen",
"SUBTITLE_DELAY_INCREASE": "Ondertitels later tonen",
"SUBTITLE_DELAY_RESET": "Ondertitelvertraging resetten",
"SUBTITLE_SIZE": "Ondertitelgrootte",
"SUBTITLE_COLOR": "Ondertitelkleur",
"SUBTITLE_COLOR_DEFAULT": "Standaard",
"SUBTITLE_COLOR_WHITE": "Wit",
"SUBTITLE_COLOR_YELLOW": "Geel",
"SUBTITLE_COLOR_CYAN": "Cyaan"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "Włącz obraz w obrazie",
"EXIT_PICTURE_IN_PICTURE": "Wyłącz obraz w obrazie",
"ENTER_FULLSCREEN": "Włącz pełny ekran",
"EXIT_FULLSCREEN": "Wyłącz pełny ekran"
"EXIT_FULLSCREEN": "Wyłącz pełny ekran",
"LOAD_SUBTITLE_FILE": "Wczytaj plik napisów…",
"SUBTITLE_DELAY": "Opóźnienie napisów",
"SUBTITLE_DELAY_DECREASE": "Pokaż napisy wcześniej",
"SUBTITLE_DELAY_INCREASE": "Pokaż napisy później",
"SUBTITLE_DELAY_RESET": "Zresetuj opóźnienie napisów",
"SUBTITLE_SIZE": "Rozmiar napisów",
"SUBTITLE_COLOR": "Kolor napisów",
"SUBTITLE_COLOR_DEFAULT": "Domyślny",
"SUBTITLE_COLOR_WHITE": "Biały",
"SUBTITLE_COLOR_YELLOW": "Żółty",
"SUBTITLE_COLOR_CYAN": "Cyjan"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "Ativar imagem em imagem",
"EXIT_PICTURE_IN_PICTURE": "Sair de imagem em imagem",
"ENTER_FULLSCREEN": "Entrar em tela cheia",
"EXIT_FULLSCREEN": "Sair da tela cheia"
"EXIT_FULLSCREEN": "Sair da tela cheia",
"LOAD_SUBTITLE_FILE": "Carregar ficheiro de legendas…",
"SUBTITLE_DELAY": "Atraso das legendas",
"SUBTITLE_DELAY_DECREASE": "Mostrar legendas mais cedo",
"SUBTITLE_DELAY_INCREASE": "Mostrar legendas mais tarde",
"SUBTITLE_DELAY_RESET": "Repor atraso das legendas",
"SUBTITLE_SIZE": "Tamanho das legendas",
"SUBTITLE_COLOR": "Cor das legendas",
"SUBTITLE_COLOR_DEFAULT": "Predefinição",
"SUBTITLE_COLOR_WHITE": "Branco",
"SUBTITLE_COLOR_YELLOW": "Amarelo",
"SUBTITLE_COLOR_CYAN": "Ciano"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "Включить режим «картинка в картинке»",
"EXIT_PICTURE_IN_PICTURE": "Выйти из режима «картинка в картинке»",
"ENTER_FULLSCREEN": "Во весь экран",
"EXIT_FULLSCREEN": "Выйти из полноэкранного режима"
"EXIT_FULLSCREEN": "Выйти из полноэкранного режима",
"LOAD_SUBTITLE_FILE": "Загрузить файл субтитров…",
"SUBTITLE_DELAY": "Задержка субтитров",
"SUBTITLE_DELAY_DECREASE": "Показывать субтитры раньше",
"SUBTITLE_DELAY_INCREASE": "Показывать субтитры позже",
"SUBTITLE_DELAY_RESET": "Сбросить задержку субтитров",
"SUBTITLE_SIZE": "Размер субтитров",
"SUBTITLE_COLOR": "Цвет субтитров",
"SUBTITLE_COLOR_DEFAULT": "По умолчанию",
"SUBTITLE_COLOR_WHITE": "Белый",
"SUBTITLE_COLOR_YELLOW": "Жёлтый",
"SUBTITLE_COLOR_CYAN": "Голубой"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "Resim içinde resim modunu aç",
"EXIT_PICTURE_IN_PICTURE": "Resim içinde resim modundan çık",
"ENTER_FULLSCREEN": "Tam ekrana geç",
"EXIT_FULLSCREEN": "Tam ekrandan çık"
"EXIT_FULLSCREEN": "Tam ekrandan çık",
"LOAD_SUBTITLE_FILE": "Altyazı dosyası yükle…",
"SUBTITLE_DELAY": "Altyazı gecikmesi",
"SUBTITLE_DELAY_DECREASE": "Altyazıları daha erken göster",
"SUBTITLE_DELAY_INCREASE": "Altyazıları daha geç göster",
"SUBTITLE_DELAY_RESET": "Altyazı gecikmesini sıfırla",
"SUBTITLE_SIZE": "Altyazı boyutu",
"SUBTITLE_COLOR": "Altyazı rengi",
"SUBTITLE_COLOR_DEFAULT": "Varsayılan",
"SUBTITLE_COLOR_WHITE": "Beyaz",
"SUBTITLE_COLOR_YELLOW": "Sarı",
"SUBTITLE_COLOR_CYAN": "Camgöbeği"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "进入画中画",
"EXIT_PICTURE_IN_PICTURE": "退出画中画",
"ENTER_FULLSCREEN": "进入全屏",
"EXIT_FULLSCREEN": "退出全屏"
"EXIT_FULLSCREEN": "退出全屏",
"LOAD_SUBTITLE_FILE": "加载字幕文件…",
"SUBTITLE_DELAY": "字幕延迟",
"SUBTITLE_DELAY_DECREASE": "提前显示字幕",
"SUBTITLE_DELAY_INCREASE": "延后显示字幕",
"SUBTITLE_DELAY_RESET": "重置字幕延迟",
"SUBTITLE_SIZE": "字幕大小",
"SUBTITLE_COLOR": "字幕颜色",
"SUBTITLE_COLOR_DEFAULT": "默认",
"SUBTITLE_COLOR_WHITE": "白色",
"SUBTITLE_COLOR_YELLOW": "黄色",
"SUBTITLE_COLOR_CYAN": "青色"
}
},
"PLAYBACK_DIAGNOSTICS": {
+12 -1
View File
@@ -555,7 +555,18 @@
"ENTER_PICTURE_IN_PICTURE": "進入子母畫面",
"EXIT_PICTURE_IN_PICTURE": "退出子母畫面",
"ENTER_FULLSCREEN": "進入全螢幕",
"EXIT_FULLSCREEN": "離開全螢幕"
"EXIT_FULLSCREEN": "離開全螢幕",
"LOAD_SUBTITLE_FILE": "載入字幕檔案…",
"SUBTITLE_DELAY": "字幕延遲",
"SUBTITLE_DELAY_DECREASE": "提前顯示字幕",
"SUBTITLE_DELAY_INCREASE": "延後顯示字幕",
"SUBTITLE_DELAY_RESET": "重設字幕延遲",
"SUBTITLE_SIZE": "字幕大小",
"SUBTITLE_COLOR": "字幕顏色",
"SUBTITLE_COLOR_DEFAULT": "預設",
"SUBTITLE_COLOR_WHITE": "白色",
"SUBTITLE_COLOR_YELLOW": "黃色",
"SUBTITLE_COLOR_CYAN": "青色"
}
},
"PLAYBACK_DIAGNOSTICS": {
+100 -1
View File
@@ -172,7 +172,8 @@ interface PlayerController {
### Capabilities
`PlayerControlsCapabilities` contains booleans for `seek`, `volume`,
`audioTracks`, `subtitles`, `qualityLevels`, `playbackSpeed`, `aspectRatio`,
`audioTracks`, `subtitles`, `externalSubtitles`, `subtitleDelay`,
`subtitleStyle`, `qualityLevels`, `playbackSpeed`, `aspectRatio`,
`recording`, `pictureInPicture`, `fullscreen`, and `seriesNavigation`.
The default is all-false. An adapter enables only features that its engine and
@@ -209,6 +210,7 @@ owner.
- `seekTo` / `seekBy`
- `setVolume`
- `setAudioTrack` / `setSubtitleTrack`
- `addExternalSubtitleFile` / `setSubtitleDelay` / `setSubtitleStyle`
- `setQualityLevel` (`AUTO_QUALITY_LEVEL_ID` = `-1` re-enables auto)
- `setPlaybackSpeed`
- `setAspectRatio`
@@ -647,6 +649,103 @@ so vendor storage cannot override the app-wide preference. With the token
disabled, the existing ArtPlayer options, HLS audio settings, skin, source
semantics, stored volume behavior, and series navigation remain unchanged.
## Advanced subtitle support
The subtitle popover carries three capability-gated extensions beyond track
selection (#1408): loading an external subtitle file, adjusting the subtitle
timing offset, and styling subtitle text (size + color). Each is honest per
engine — an engine that cannot support a control simply never advertises the
capability, and the UI is not rendered.
Contract surface:
- capabilities `externalSubtitles`, `subtitleDelay`, `subtitleStyle`;
- state `subtitleDelaySeconds` (positive = subtitles appear later) and
`subtitleStyle` (`PlayerSubtitleStyle { sizePercent, color }`); and
- commands `addExternalSubtitleFile()` (fire-and-forget; the adapter owns its
environment's picker), `setSubtitleDelay(seconds)`, and
`setSubtitleStyle(style)`.
The subtitle menu stays reachable with an empty track list whenever
`externalSubtitles` is set — loading a file is what creates the first track.
Delay and style rows keep the popover open, because these settings are tuned
iteratively against the running video (`ControlsSubtitleSettings` owns those
interactions); the load action closes it because a file dialog opens on top.
Persistence: the style (size/color) is a cross-engine preference stored under
the `subtitleStyle` localStorage key (`subtitle-style.ts`), the same mechanism
as the shared `volume` key, and is normalized/clamped on every read and write.
The delay and any loaded file are deliberately per-session/per-source — they
correct one specific stream.
The canonical `PlayerSubtitleStyle` shape and the clamp/normalize rules
(delay limit, size bounds, color validation) live in
`@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). The renderer
applies them to user input and the Electron main process re-applies the exact
same implementation to untrusted IPC payloads — deliberate defense-in-depth
with a single source of truth, so widening a limit on one side cannot
silently re-clamp on the other.
Per-engine implementations:
- **HTML5 + ArtPlayer (shared-controls mode, neutral source bridge).** The
picker is a renderer-side DOM file input (`.srt`/`.vtt` only; works in the
PWA and Electron alike, and no filesystem path ever enters the app). File
bytes are decoded encoding-aware (`decodeExternalSubtitleBytes`: UTF-16
BOMs, strict UTF-8, then `chooseLegacySingleByteDecode`, which picks
between Windows-1251 and Windows-1252 by the plausibility of the 1251
candidate's decoded words — pure-Cyrillic words vote for 1251, words
mixing Cyrillic with ASCII letters vote against (misread Latin text like
"était" decodes to the mixed-script "йtait" that real subtitles never
contain), and Cyrillic must also carry a meaningful share of all letters
so an isolated accented CP1252 word ("À table" → "А table") cannot flip
the file), because `Blob.text()`'s silent UTF-8 substitution turns common
legacy-encoded SRT files into mojibake. `WebVideoExternalSubtitles` parses
the file (`external-subtitle-cues.util.ts`) and renders it through a native
`TextTrack` on the video element, so it works under every source kind. The
native track enumeration excludes externally owned tracks — ownership is
tracked for every track the session EVER created, because `addTextTrack`
tracks cannot leave the element and per-source ownership would let stale or
attach-failed tracks reappear as ghost engine tracks. `WebVideoSourceTracks`
merges external tracks into the subtitle listing with IDs from 100000 up,
routing selection so exactly one owner (engine or external) is active;
external selection deselects the engine BEFORE setting track modes, since
hls.js reacts to `subtitleTrack = -1` by disabling every subtitle-kind
`TextTrack` on the element. A pick captures the source generation and is
discarded if the stream changed while the dialog was open (mirroring the
Embedded MPV runner's session recheck). The delay capability is
runtime-gated on an external track being the SELECTED one — only owned cues
can be re-timed exactly, and with an engine track active the row would be
enabled yet visually inert. Negatively shifted cues keep their real
(possibly negative) times, which are valid and simply never active;
clamping them to t≈0 would stack every pre-roll cue at playback start.
Style applies through a scoped `::cue` rule (`WebVideoSubtitleStyle`),
which covers embedded, hls.js-managed, and external native cues. ASS
rendering would need libass and is out of scope for the web engines.
- **Embedded MPV frame-copy.** The helper protocol gained `sub-add`,
`sub-delay`, `sub-scale`, and `sub-color` commands. The picker is a
main-process open dialog (`.srt/.ass/.ssa/.vtt/.sub` — mpv renders ASS
natively), and the renderer only ever forwards the returned path over the
dedicated IPC (`EMBEDDED_MPV_ADD_SUBTITLE` etc.); delay applies to every
subtitle track. mpv does not report these values back through the session
snapshot, so `EmbeddedMpvSubtitleSettings` keeps the authoritative
renderer-side values: the delay resets per session, and a non-default
persisted style is re-applied to each new session. `sub-color` affects
mpv's text-subtitle rendering; ASS files keep their embedded styling.
Runtime coverage: the packaged Linux frame-copy smoke
(`apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts`)
drives `addEmbeddedMpvSubtitle` with a fixture file against the real
packaged helper and asserts the track appears in the session snapshot,
plus round-trips the delay/style IPC. The native file dialog itself
(`selectEmbeddedMpvSubtitleFile`) cannot be automated and is verified
manually.
- **Not wired (capabilities stay false):** Video.js shared mode (its emulated
text-track display needs a separate remote-track + CSS integration — a
follow-up), the vendor-chrome (preference-off) web players by design, the
Embedded MPV native-view legacy dock, the Linux out-of-process native path
(which exports no subtitle commands), and external MPV/VLC, which own their
own UI.
## Embedded MPV rendering constraints
The shared contract does not replace either Embedded MPV renderer. The host
+1
View File
@@ -5,6 +5,7 @@ export * from './lib/content-metadata.interface';
export * from './lib/dev-logger.util';
export * from './lib/download-metadata.interface';
export * from './lib/embedded-mpv-session.interface';
export * from './lib/subtitle-style.util';
export * from './lib/electron-api.interface';
export * from './lib/epg-channel-metadata.model';
export * from './lib/epg-channel-with-programs.interface';
@@ -2,6 +2,7 @@ import {
EmbeddedMpvBounds,
EmbeddedMpvRecordingStartOptions,
EmbeddedMpvSession,
EmbeddedMpvSubtitleStyle,
EmbeddedMpvSupport,
} from './embedded-mpv-session.interface';
import { ContentMetadataPatch } from './content-metadata.interface';
@@ -1164,6 +1165,21 @@ export interface ElectronBridgeApi {
sessionId: string,
trackId: number
) => Promise<EmbeddedMpvSession | null>;
/** Loads an external subtitle file (absolute path) via mpv `sub-add`. */
addEmbeddedMpvSubtitle?: (
sessionId: string,
filePath: string
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvSubtitleDelay?: (
sessionId: string,
seconds: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvSubtitleStyle?: (
sessionId: string,
style: EmbeddedMpvSubtitleStyle
) => Promise<EmbeddedMpvSession | null>;
/** Opens the main-process subtitle file dialog; null when cancelled. */
selectEmbeddedMpvSubtitleFile?: () => Promise<string | null>;
setEmbeddedMpvSpeed?: (
sessionId: string,
speed: number
@@ -1,3 +1,4 @@
import type { PlayerSubtitleStyle } from './subtitle-style.util';
import type { RecordingStartMetadata } from './recording-metadata.interface';
export type EmbeddedMpvSessionStatus =
@@ -22,8 +23,22 @@ export interface EmbeddedMpvCapabilities {
aspectOverride: boolean;
screenshot: boolean;
recording: boolean;
/** Loading an external subtitle file via `sub-add` (frame-copy engine). */
externalSubtitles?: boolean;
/** Adjusting `sub-delay` at runtime (frame-copy engine). */
subtitleDelay?: boolean;
/** Adjusting `sub-scale`/`sub-color` at runtime (frame-copy engine). */
subtitleStyle?: boolean;
}
/**
* Subtitle presentation preferences forwarded to mpv: `sizePercent` maps to
* `sub-scale` (100 = 1.0); `color` maps to `sub-color`, null restores mpv's
* default. Alias of the canonical shared shape so the renderer controls and
* the IPC contract cannot drift structurally.
*/
export type EmbeddedMpvSubtitleStyle = PlayerSubtitleStyle;
export type EmbeddedMpvEngine = 'native' | 'frame-copy';
export interface EmbeddedMpvSupport {
@@ -75,6 +75,13 @@ export const EMBEDDED_MPV_SET_VOLUME = 'EMBEDDED_MPV_SET_VOLUME';
export const EMBEDDED_MPV_SET_AUDIO_TRACK = 'EMBEDDED_MPV_SET_AUDIO_TRACK';
export const EMBEDDED_MPV_SET_SUBTITLE_TRACK =
'EMBEDDED_MPV_SET_SUBTITLE_TRACK';
export const EMBEDDED_MPV_ADD_SUBTITLE = 'EMBEDDED_MPV_ADD_SUBTITLE';
export const EMBEDDED_MPV_SET_SUBTITLE_DELAY =
'EMBEDDED_MPV_SET_SUBTITLE_DELAY';
export const EMBEDDED_MPV_SET_SUBTITLE_STYLE =
'EMBEDDED_MPV_SET_SUBTITLE_STYLE';
export const EMBEDDED_MPV_SELECT_SUBTITLE_FILE =
'EMBEDDED_MPV_SELECT_SUBTITLE_FILE';
export const EMBEDDED_MPV_SET_SPEED = 'EMBEDDED_MPV_SET_SPEED';
export const EMBEDDED_MPV_SET_ASPECT = 'EMBEDDED_MPV_SET_ASPECT';
export const EMBEDDED_MPV_START_RECORDING = 'EMBEDDED_MPV_START_RECORDING';
@@ -0,0 +1,71 @@
/**
* Canonical subtitle presentation contract shared by the renderer controls
* (`libs/ui/playback`) and the Electron main process. The clamp/normalize
* rules live HERE so the two sides cannot drift: the renderer applies them to
* user input, and the main process re-applies the exact same rules to
* untrusted IPC payloads (deliberate defense-in-depth, same implementation).
*/
/**
* Engine-neutral subtitle presentation preferences. `sizePercent` is relative
* to the engine's default rendering size (100 = default); `color` is a
* lowercase CSS hex color, or null for the engine default.
*/
export interface PlayerSubtitleStyle {
sizePercent: number;
color: string | null;
}
export const DEFAULT_SUBTITLE_STYLE: PlayerSubtitleStyle = {
sizePercent: 100,
color: null,
};
export const SUBTITLE_DELAY_LIMIT_SECONDS = 60;
export const SUBTITLE_SIZE_MIN_PERCENT = 25;
export const SUBTITLE_SIZE_MAX_PERCENT = 400;
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
export function clampSubtitleDelay(seconds: number): number {
if (!Number.isFinite(seconds)) {
return 0;
}
const clamped = Math.max(
-SUBTITLE_DELAY_LIMIT_SECONDS,
Math.min(SUBTITLE_DELAY_LIMIT_SECONDS, seconds)
);
// Avoid float drift from repeated ±0.5 steps ("0.30000000000000004").
return Math.round(clamped * 1000) / 1000;
}
export function normalizeSubtitleStyle(value: unknown): PlayerSubtitleStyle {
if (typeof value !== 'object' || value === null) {
return { ...DEFAULT_SUBTITLE_STYLE };
}
const candidate = value as Partial<PlayerSubtitleStyle>;
const sizePercent =
typeof candidate.sizePercent === 'number' &&
Number.isFinite(candidate.sizePercent)
? Math.max(
SUBTITLE_SIZE_MIN_PERCENT,
Math.min(
SUBTITLE_SIZE_MAX_PERCENT,
Math.round(candidate.sizePercent)
)
)
: DEFAULT_SUBTITLE_STYLE.sizePercent;
const color =
typeof candidate.color === 'string' &&
HEX_COLOR_PATTERN.test(candidate.color)
? candidate.color.toLowerCase()
: null;
return { sizePercent, color };
}
export function isDefaultSubtitleStyle(style: PlayerSubtitleStyle): boolean {
return (
style.sizePercent === DEFAULT_SUBTITLE_STYLE.sizePercent &&
style.color === DEFAULT_SUBTITLE_STYLE.color
);
}
@@ -339,4 +339,75 @@ describe('EmbeddedMpvCommandRunner', () => {
expect(recording).toEqual({ active: false, targetPath: '/tmp/rec.ts' });
expect(session()?.recording).toEqual(recording);
});
it('addExternalSubtitle loads the picked file into the session', async () => {
electron.selectEmbeddedMpvSubtitleFile = jest
.fn()
.mockResolvedValue('/subs/movie.srt');
electron.addEmbeddedMpvSubtitle = jest
.fn()
.mockResolvedValue(createSession());
await expect(runner.addExternalSubtitle()).resolves.toBe(true);
expect(electron.addEmbeddedMpvSubtitle).toHaveBeenCalledWith(
'mpv-1',
'/subs/movie.srt'
);
});
it('addExternalSubtitle is a no-op on dialog cancel', async () => {
electron.selectEmbeddedMpvSubtitleFile = jest
.fn()
.mockResolvedValue(null);
electron.addEmbeddedMpvSubtitle = jest.fn();
await expect(runner.addExternalSubtitle()).resolves.toBe(false);
expect(electron.addEmbeddedMpvSubtitle).not.toHaveBeenCalled();
});
it('addExternalSubtitle drops a pick that outlived its session', async () => {
let resolveDialog!: (value: string) => void;
electron.selectEmbeddedMpvSubtitleFile = jest.fn().mockReturnValue(
new Promise<string>((resolve) => {
resolveDialog = resolve;
})
);
electron.addEmbeddedMpvSubtitle = jest.fn();
const pending = runner.addExternalSubtitle();
sessionId.set('mpv-2');
resolveDialog('/subs/movie.srt');
await expect(pending).resolves.toBe(false);
expect(electron.addEmbeddedMpvSubtitle).not.toHaveBeenCalled();
});
it('forwards subtitle delay and style to the bridge', async () => {
electron.setEmbeddedMpvSubtitleDelay = jest
.fn()
.mockResolvedValue(createSession());
electron.setEmbeddedMpvSubtitleStyle = jest
.fn()
.mockResolvedValue(createSession());
await runner.setSubtitleDelay(1.5);
expect(electron.setEmbeddedMpvSubtitleDelay).toHaveBeenCalledWith(
'mpv-1',
1.5
);
await runner.setSubtitleStyle({ sizePercent: 150, color: '#ffffff' });
expect(electron.setEmbeddedMpvSubtitleStyle).toHaveBeenCalledWith(
'mpv-1',
{ sizePercent: 150, color: '#ffffff' }
);
});
it('subtitle commands no-op on bridges without the new methods', async () => {
await expect(runner.addExternalSubtitle()).resolves.toBe(false);
await expect(runner.setSubtitleDelay(1)).resolves.toBeUndefined();
await expect(
runner.setSubtitleStyle({ sizePercent: 100, color: null })
).resolves.toBeUndefined();
});
});
@@ -1,6 +1,7 @@
import { Signal, WritableSignal } from '@angular/core';
import {
EmbeddedMpvSession,
EmbeddedMpvSubtitleStyle,
RecordingStartMetadata,
} from '@iptvnator/shared/interfaces';
@@ -88,6 +89,48 @@ export class EmbeddedMpvCommandRunner {
await this.run(id, () => setSubtitleTrack(id, trackId));
}
/**
* Opens the main-process subtitle file dialog and, when the user picks a
* file, hands its path to mpv. Returns true when a file was loaded.
*/
async addExternalSubtitle(): Promise<boolean> {
const id = this.ctx.sessionId();
const electron = this.bridge();
const selectSubtitleFile = electron?.selectEmbeddedMpvSubtitleFile;
const addSubtitle = electron?.addEmbeddedMpvSubtitle;
if (!id || !selectSubtitleFile || !addSubtitle) {
return false;
}
const filePath = await this.guardIpc(selectSubtitleFile);
// The dialog is modal-slow; the session may be gone by the time the
// user picked a file.
if (!filePath || this.ctx.sessionId() !== id) {
return false;
}
const updated = await this.run(id, () => addSubtitle(id, filePath));
return updated !== null;
}
async setSubtitleDelay(seconds: number): Promise<void> {
const id = this.ctx.sessionId();
const electron = this.bridge();
if (!id || !electron?.setEmbeddedMpvSubtitleDelay) {
return;
}
const setSubtitleDelay = electron.setEmbeddedMpvSubtitleDelay;
await this.run(id, () => setSubtitleDelay(id, seconds));
}
async setSubtitleStyle(style: EmbeddedMpvSubtitleStyle): Promise<void> {
const id = this.ctx.sessionId();
const electron = this.bridge();
if (!id || !electron?.setEmbeddedMpvSubtitleStyle) {
return;
}
const setSubtitleStyle = electron.setEmbeddedMpvSubtitleStyle;
await this.run(id, () => setSubtitleStyle(id, style));
}
async setSpeed(speed: number): Promise<void> {
const id = this.ctx.sessionId();
const electron = this.bridge();
@@ -44,6 +44,9 @@ function supported(
aspectOverride: true,
screenshot: false,
recording: true,
externalSubtitles: true,
subtitleDelay: true,
subtitleStyle: true,
},
...overrides,
};
@@ -195,6 +198,9 @@ describe('EmbeddedMpvControlsAdapter', () => {
volume: true,
audioTracks: true,
subtitles: true,
externalSubtitles: true,
subtitleDelay: true,
subtitleStyle: true,
qualityLevels: false,
playbackSpeed: true,
aspectRatio: true,
@@ -221,6 +227,9 @@ describe('EmbeddedMpvControlsAdapter', () => {
volume: true,
audioTracks: true,
subtitles: false,
externalSubtitles: false,
subtitleDelay: false,
subtitleStyle: false,
qualityLevels: false,
playbackSpeed: false,
aspectRatio: false,
@@ -40,6 +40,7 @@ import {
import { resolveRecordingFeedback } from './embedded-mpv-controls-recording-feedback';
import { EmbeddedMpvControlsRecording } from './embedded-mpv-controls-recording';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
import { EmbeddedMpvSubtitleSettings } from './embedded-mpv-subtitle-settings';
export interface EmbeddedMpvControlsContext {
readonly playback: Signal<ResolvedPortalPlayback>;
@@ -91,6 +92,9 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
private readonly activeSessionId = computed(
() => this.controller.session()?.id ?? null
);
private readonly subtitleSettings = new EmbeddedMpvSubtitleSettings(
this.controller
);
private readonly recordingTransitionKey = computed(() => {
const playbackIdentity = this.recordingPlaybackIdentity();
const sessionId = this.activeSessionId();
@@ -115,6 +119,10 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
volume: true,
audioTracks: true,
subtitles: optionalCapabilities?.subtitles ?? false,
externalSubtitles:
optionalCapabilities?.externalSubtitles ?? false,
subtitleDelay: optionalCapabilities?.subtitleDelay ?? false,
subtitleStyle: optionalCapabilities?.subtitleStyle ?? false,
playbackSpeed: optionalCapabilities?.playbackSpeed ?? false,
aspectRatio: optionalCapabilities?.aspectOverride ?? false,
recording: optionalCapabilities?.recording ?? false,
@@ -180,6 +188,8 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
})
),
subtitlesEnabled: (session?.selectedSubtitleTrackId ?? -1) >= 0,
subtitleDelaySeconds: this.subtitleSettings.delaySeconds(),
subtitleStyle: this.subtitleSettings.style(),
// MPV demuxes one program; no HLS/DASH rendition list is surfaced.
qualityLevels: [],
qualityAutoEnabled: true,
@@ -214,6 +224,10 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
setVolume: (value) => void this.controller.applyVolume(value),
setAudioTrack: (id) => void this.controller.setAudioTrack(id),
setSubtitleTrack: (id) => void this.controller.setSubtitleTrack(id),
addExternalSubtitleFile: () =>
this.subtitleSettings.addExternalSubtitle(),
setSubtitleDelay: (seconds) => this.subtitleSettings.setDelay(seconds),
setSubtitleStyle: (style) => this.subtitleSettings.setStyle(style),
setQualityLevel: () => undefined,
setPlaybackSpeed: (speed) => void this.controller.setSpeed(speed),
setAspectRatio: (value) => void this.controller.setAspect(value),
@@ -243,6 +257,12 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
);
});
// Delay is per-session; the persisted style is re-applied per session.
effect(() => {
const sessionId = this.activeSessionId();
untracked(() => this.subtitleSettings.syncSession(sessionId));
});
effect(() => {
const session = this.controller.session();
const playbackIdentity = this.recordingPlaybackIdentity();
@@ -10,6 +10,7 @@ import {
import {
EmbeddedMpvBounds,
EmbeddedMpvSession,
EmbeddedMpvSubtitleStyle,
EmbeddedMpvSupport,
RecordingStartMetadata,
ResolvedPortalPlayback,
@@ -301,6 +302,13 @@ export class EmbeddedMpvSessionController {
this.commands.setAudioTrack(trackId);
readonly setSubtitleTrack = (trackId: number): Promise<void> =>
this.commands.setSubtitleTrack(trackId);
readonly addExternalSubtitle = (): Promise<boolean> =>
this.commands.addExternalSubtitle();
readonly setSubtitleDelay = (seconds: number): Promise<void> =>
this.commands.setSubtitleDelay(seconds);
readonly setSubtitleStyle = (
style: EmbeddedMpvSubtitleStyle
): Promise<void> => this.commands.setSubtitleStyle(style);
readonly setSpeed = (speed: number): Promise<void> =>
this.commands.setSpeed(speed);
readonly setAspect = (aspect: string): Promise<void> =>
@@ -0,0 +1,104 @@
import {
DEFAULT_SUBTITLE_STYLE,
SUBTITLE_STYLE_STORAGE_KEY,
} from '../player-controls/subtitle-style';
import { EmbeddedMpvSubtitleSettings } from './embedded-mpv-subtitle-settings';
import type { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
function createControllerMock() {
return {
addExternalSubtitle: jest.fn().mockResolvedValue(true),
setSubtitleDelay: jest.fn().mockResolvedValue(undefined),
setSubtitleStyle: jest.fn().mockResolvedValue(undefined),
};
}
describe('EmbeddedMpvSubtitleSettings', () => {
let controller: ReturnType<typeof createControllerMock>;
function createSettings(): EmbeddedMpvSubtitleSettings {
return new EmbeddedMpvSubtitleSettings(
controller as unknown as EmbeddedMpvSessionController
);
}
beforeEach(() => {
controller = createControllerMock();
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('starts with the stored style and a zero delay', () => {
localStorage.setItem(
SUBTITLE_STYLE_STORAGE_KEY,
JSON.stringify({ sizePercent: 150, color: '#ffffff' })
);
const settings = createSettings();
expect(settings.style()).toEqual({
sizePercent: 150,
color: '#ffffff',
});
expect(settings.delaySeconds()).toBe(0);
});
it('clamps and forwards the delay to the session controller', () => {
const settings = createSettings();
settings.setDelay(90);
expect(settings.delaySeconds()).toBe(60);
expect(controller.setSubtitleDelay).toHaveBeenCalledWith(60);
});
it('normalizes, persists, and forwards style changes', () => {
const settings = createSettings();
settings.setStyle({ sizePercent: 125, color: '#FFE94F' });
expect(settings.style()).toEqual({
sizePercent: 125,
color: '#ffe94f',
});
expect(controller.setSubtitleStyle).toHaveBeenCalledWith({
sizePercent: 125,
color: '#ffe94f',
});
expect(
JSON.parse(localStorage.getItem(SUBTITLE_STYLE_STORAGE_KEY) ?? '')
).toEqual({ sizePercent: 125, color: '#ffe94f' });
});
it('resets the delay per session and re-applies a non-default style', () => {
const settings = createSettings();
settings.syncSession('session-1');
// Default style: nothing pushed to a fresh session.
expect(controller.setSubtitleStyle).not.toHaveBeenCalled();
settings.setStyle({ sizePercent: 200, color: null });
settings.setDelay(2);
controller.setSubtitleStyle.mockClear();
settings.syncSession('session-2');
expect(settings.delaySeconds()).toBe(0);
expect(controller.setSubtitleStyle).toHaveBeenCalledWith({
sizePercent: 200,
color: null,
});
// Re-observing the same session must not re-apply or reset anything.
settings.setDelay(1.5);
controller.setSubtitleStyle.mockClear();
settings.syncSession('session-2');
expect(settings.delaySeconds()).toBe(1.5);
expect(controller.setSubtitleStyle).not.toHaveBeenCalled();
});
it('does not push the style while no session is active', () => {
const settings = createSettings();
settings.setStyle({ sizePercent: 150, color: null });
controller.setSubtitleStyle.mockClear();
settings.syncSession(null);
expect(controller.setSubtitleStyle).not.toHaveBeenCalled();
expect(settings.style()).not.toEqual(DEFAULT_SUBTITLE_STYLE);
});
});
@@ -0,0 +1,56 @@
import { signal } from '@angular/core';
import type { PlayerSubtitleStyle } from '../player-controls/player-controls.model';
import {
clampSubtitleDelay,
isDefaultSubtitleStyle,
normalizeSubtitleStyle,
persistSubtitleStyle,
readStoredSubtitleStyle,
} from '../player-controls/subtitle-style';
import type { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
/**
* Renderer-side owner of the Embedded MPV subtitle delay and style. mpv does
* not report `sub-delay`/`sub-scale`/`sub-color` back through the session
* snapshot, so this class keeps the authoritative optimistic values: delay is
* per-session (it corrects one stream) and resets when the session changes,
* while the style is the shared persisted preference and is re-applied to
* every new session.
*/
export class EmbeddedMpvSubtitleSettings {
readonly delaySeconds = signal(0);
readonly style = signal<PlayerSubtitleStyle>(readStoredSubtitleStyle());
private appliedSessionId: string | null = null;
constructor(private readonly controller: EmbeddedMpvSessionController) {}
/** Called from an effect whenever the active session id changes. */
syncSession(sessionId: string | null): void {
if (sessionId === this.appliedSessionId) {
return;
}
this.appliedSessionId = sessionId;
this.delaySeconds.set(0);
if (sessionId && !isDefaultSubtitleStyle(this.style())) {
void this.controller.setSubtitleStyle(this.style());
}
}
setDelay(seconds: number): void {
const clamped = clampSubtitleDelay(seconds);
this.delaySeconds.set(clamped);
void this.controller.setSubtitleDelay(clamped);
}
setStyle(style: PlayerSubtitleStyle): void {
const normalized = normalizeSubtitleStyle(style);
this.style.set(normalized);
persistSubtitleStyle(normalized);
void this.controller.setSubtitleStyle(normalized);
}
addExternalSubtitle(): void {
void this.controller.addExternalSubtitle();
}
}
@@ -18,6 +18,9 @@ describe('ControlsMenuSelection', () => {
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
@@ -32,6 +32,11 @@ export class ControlsMenuSelection {
this.apply('subtitle', (c) => c.setSubtitleTrack(trackId));
}
/** Opens the engine's subtitle file picker and closes the popover. */
externalSubtitle(): void {
this.apply('subtitle', (c) => c.addExternalSubtitleFile());
}
qualityLevel(levelId: number): void {
this.apply('quality', (c) => c.setQualityLevel(levelId));
}
@@ -27,10 +27,13 @@ function getControlsMenuAvailability(
showControls &&
capabilities.audioTracks &&
state.audioTracks.length > 1,
// External subtitle loading keeps the menu reachable with an empty
// track list — the "Load subtitle file…" action is how the first
// track appears.
subtitle:
showControls &&
capabilities.subtitles &&
state.subtitleTracks.length > 0,
((capabilities.subtitles && state.subtitleTracks.length > 0) ||
capabilities.externalSubtitles),
quality:
showControls &&
capabilities.qualityLevels &&
@@ -0,0 +1,60 @@
import type {
PlayerController,
PlayerSubtitleStyle,
} from './player-controls.model';
import { clampSubtitleDelay } from './subtitle-style';
export interface ControlsSubtitleSettingsDeps {
controller: () => PlayerController;
/** Reveal without rescheduling auto-hide (menu stays open while tuning). */
revealSticky: () => void;
}
/**
* Owns the delay/size/color interactions of the subtitle popover: guard on the
* capability, keep the controls revealed, clamp, and forward the command. The
* popover intentionally stays open — these settings are tuned iteratively
* against the running video.
*/
export class ControlsSubtitleSettings {
constructor(private readonly deps: ControlsSubtitleSettingsDeps) {}
adjustDelay(deltaSeconds: number): void {
const controller = this.deps.controller();
this.applyDelay(
controller.state().subtitleDelaySeconds + deltaSeconds
);
}
resetDelay(): void {
this.applyDelay(0);
}
setSize(sizePercent: number): void {
const controller = this.deps.controller();
this.applyStyle({ ...controller.state().subtitleStyle, sizePercent });
}
setColor(color: string | null): void {
const controller = this.deps.controller();
this.applyStyle({ ...controller.state().subtitleStyle, color });
}
private applyDelay(seconds: number): void {
const controller = this.deps.controller();
if (!controller.capabilities().subtitleDelay) {
return;
}
this.deps.revealSticky();
controller.commands.setSubtitleDelay(clampSubtitleDelay(seconds));
}
private applyStyle(style: PlayerSubtitleStyle): void {
const controller = this.deps.controller();
if (!controller.capabilities().subtitleStyle) {
return;
}
this.deps.revealSticky();
controller.commands.setSubtitleStyle(style);
}
}
@@ -50,8 +50,12 @@ export function createControlsViewModel(deps: ControlsViewModelDeps) {
const hasAudioTracks = computed(
() => capabilities().audioTracks && state().audioTracks.length > 1
);
// External subtitle loading renders the button even with no embedded
// tracks yet — loading a file is what creates the first one.
const hasSubtitleTracks = computed(
() => capabilities().subtitles && state().subtitleTracks.length > 0
() =>
(capabilities().subtitles && state().subtitleTracks.length > 0) ||
capabilities().externalSubtitles
);
const hasQualityLevels = computed(
() => capabilities().qualityLevels && state().qualityLevels.length > 1
@@ -1,7 +1,9 @@
export * from './player-controls.model';
export * from './player-controls-defaults';
export * from './subtitle-style';
export * from './player-controls.component';
export * from './controls-menu-state';
export * from './controls-subtitle-settings';
export * from './controls-feedback';
export * from './controls-fullscreen';
export * from './controls-shortcuts';
@@ -3,12 +3,16 @@ import type {
PlayerControlsState,
PlayerPreset,
} from './player-controls.model';
import { DEFAULT_SUBTITLE_STYLE } from './subtitle-style';
export const DEFAULT_PLAYER_CAPABILITIES: PlayerControlsCapabilities = {
seek: false,
volume: false,
audioTracks: false,
subtitles: false,
externalSubtitles: false,
subtitleDelay: false,
subtitleStyle: false,
qualityLevels: false,
playbackSpeed: false,
aspectRatio: false,
@@ -48,6 +52,8 @@ export function createEmptyControlsState(): PlayerControlsState {
audioTracks: [],
subtitleTracks: [],
subtitlesEnabled: false,
subtitleDelaySeconds: 0,
subtitleStyle: { ...DEFAULT_SUBTITLE_STYLE },
qualityLevels: [],
qualityAutoEnabled: true,
playbackSpeed: 1,
@@ -30,6 +30,9 @@ function createFakeController(): FakeController {
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
@@ -359,27 +359,39 @@
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
}}
</div>
<!-- Single scroll owner for the track list AND the
delay/style sections: on short players the
popover height cap must scroll everything, not
clip the sections outside the list. -->
<div class="player-controls__subtitle-body">
<div class="player-controls__track-list">
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
!state().subtitlesEnabled
"
class="player-controls__track"
[class.player-controls__track--selected]="
!state().subtitlesEnabled
"
(click)="menuSelection.subtitleTrack(-1)"
>
<span>{{
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
| translate
}}</span>
@if (!state().subtitlesEnabled) {
<mat-icon>check</mat-icon>
}
</button>
@if (
capabilities().subtitles &&
state().subtitleTracks.length > 0
) {
<button
type="button"
role="menuitemradio"
[attr.aria-checked]="
!state().subtitlesEnabled
"
class="player-controls__track"
[class.player-controls__track--selected]="
!state().subtitlesEnabled
"
(click)="
menuSelection.subtitleTrack(-1)
"
>
<span>{{
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
| translate
}}</span>
@if (!state().subtitlesEnabled) {
<mat-icon>check</mat-icon>
}
</button>
}
@for (
track of state().subtitleTracks;
track track.id
@@ -404,6 +416,214 @@
}
</button>
}
@if (capabilities().externalSubtitles) {
<button
type="button"
role="menuitem"
class="player-controls__track player-controls__load-subtitle"
data-test-id="player-controls-load-subtitle"
(click)="loadExternalSubtitle()"
>
<span>{{
'EMBEDDED_MPV.PLAYER.LOAD_SUBTITLE_FILE'
| translate
}}</span>
<mat-icon>upload_file</mat-icon>
</button>
}
</div>
@if (capabilities().subtitleDelay) {
<div
class="player-controls__subtitle-section"
data-test-id="player-controls-subtitle-delay"
>
<div
class="player-controls__subtitle-section-title"
>
{{
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY'
| translate
}}
</div>
<div
class="player-controls__subtitle-delay-row"
>
<button
mat-icon-button
type="button"
class="player-controls__button"
(click)="
subtitleSettings.adjustDelay(
-subtitleDelayStep
)
"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_DECREASE'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_DECREASE'
| translate
"
>
<mat-icon>remove</mat-icon>
</button>
<span
class="player-controls__subtitle-delay-value"
aria-live="polite"
>{{
subtitleDelayLabel(
state()
.subtitleDelaySeconds
)
}}</span
>
<button
mat-icon-button
type="button"
class="player-controls__button"
(click)="
subtitleSettings.adjustDelay(
subtitleDelayStep
)
"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_INCREASE'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_INCREASE'
| translate
"
>
<mat-icon>add</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="player-controls__button"
[disabled]="
state().subtitleDelaySeconds ===
0
"
(click)="subtitleSettings.resetDelay()"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_RESET'
| translate
"
[matTooltip]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_RESET'
| translate
"
>
<mat-icon>restart_alt</mat-icon>
</button>
</div>
</div>
}
@if (capabilities().subtitleStyle) {
<div
class="player-controls__subtitle-section"
data-test-id="player-controls-subtitle-style"
>
<div
class="player-controls__subtitle-section-title"
>
{{
'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE'
| translate
}}
</div>
<div
class="player-controls__subtitle-preset-row"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE'
| translate
"
>
@for (
preset of subtitleSizePresets;
track preset.value
) {
<button
type="button"
role="radio"
class="player-controls__subtitle-chip"
[attr.aria-checked]="
preset.value ===
state().subtitleStyle
.sizePercent
"
[class.player-controls__subtitle-chip--selected]="
preset.value ===
state().subtitleStyle
.sizePercent
"
(click)="
subtitleSettings.setSize(
preset.value
)
"
>
{{ preset.label }}
</button>
}
</div>
<div
class="player-controls__subtitle-section-title"
>
{{
'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR'
| translate
}}
</div>
<div
class="player-controls__subtitle-preset-row"
role="radiogroup"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR'
| translate
"
>
@for (
preset of subtitleColorPresets;
track preset.label
) {
<button
type="button"
role="radio"
class="player-controls__subtitle-swatch"
[class.player-controls__subtitle-swatch--default]="
preset.value === null
"
[style.--swatch-color]="
preset.value
"
[attr.aria-checked]="
preset.value ===
state().subtitleStyle.color
"
[class.player-controls__subtitle-swatch--selected]="
preset.value ===
state().subtitleStyle.color
"
[attr.aria-label]="
preset.label | translate
"
[matTooltip]="
preset.label | translate
"
(click)="
subtitleSettings.setColor(
preset.value
)
"
></button>
}
</div>
</div>
}
</div>
</div>
}
@@ -27,6 +27,9 @@ function createFakeController() {
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
@@ -27,6 +27,9 @@ function createFakeController() {
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
@@ -27,6 +27,9 @@ function createFakeController() {
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
@@ -501,6 +501,114 @@
height: 18px;
}
.player-controls__load-subtitle mat-icon {
color: rgba(255, 255, 255, 0.85);
}
// Single scroll owner for the subtitle popover's list + delay/style sections:
// on short players (e.g. the 220px portal player) the panel's max-height cap
// must scroll ALL content — sections outside the list's own scroll area would
// otherwise clip or overflow into the controls bar.
.player-controls__subtitle-body {
display: flex;
flex-direction: column;
flex: 0 1 auto;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
// Inside the shared scroll owner the list must not nest a second scroll area.
.player-controls__subtitle-body .player-controls__track-list {
flex: none;
overflow-y: visible;
}
.player-controls__subtitle-section {
flex: 0 0 auto;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.player-controls__subtitle-section-title {
padding: 0 2px 6px;
color: rgba(255, 255, 255, 0.7);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.player-controls__subtitle-delay-row {
display: flex;
align-items: center;
gap: 4px;
}
.player-controls__subtitle-delay-value {
min-width: 58px;
text-align: center;
font-variant-numeric: tabular-nums;
color: var(--pc-text);
}
.player-controls__subtitle-preset-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding-bottom: 4px;
}
.player-controls__subtitle-chip {
padding: 4px 10px;
color: var(--pc-text);
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 999px;
cursor: pointer;
font: inherit;
font-size: 0.8rem;
}
.player-controls__subtitle-chip:hover,
.player-controls__subtitle-chip:focus-visible,
.player-controls__subtitle-chip--selected {
background: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.6);
}
.player-controls__subtitle-swatch {
width: 26px;
height: 26px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-radius: 50%;
cursor: pointer;
background: var(--swatch-color, transparent);
}
// The "engine default" swatch has no color of its own: render a diagonal
// slash so it reads as "no override".
.player-controls__subtitle-swatch--default {
background:
linear-gradient(
135deg,
transparent 44%,
rgba(255, 255, 255, 0.7) 46%,
rgba(255, 255, 255, 0.7) 54%,
transparent 56%
),
rgba(255, 255, 255, 0.08);
}
.player-controls__subtitle-swatch:hover,
.player-controls__subtitle-swatch:focus-visible,
.player-controls__subtitle-swatch--selected {
border-color: #ffffff;
}
@media (max-width: 720px) {
// Keep the single-row layout when space is tight: the transport and action
// buttons hold their natural width while the middle timeline column
@@ -25,6 +25,9 @@ function createFakeController() {
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
@@ -0,0 +1,248 @@
import { WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { PlayerControlsComponent } from './player-controls.component';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
PlayerController,
} from './player-controls.model';
function createFakeController() {
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
...DEFAULT_PLAYER_CAPABILITIES,
});
const state: WritableSignal<PlayerControlsState> = signal(
createEmptyControlsState()
);
const commands: jest.Mocked<PlayerControlsCommands> = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
};
const controller: PlayerController = { capabilities, state, commands };
return { controller, capabilities, state, commands };
}
describe('PlayerControlsComponent subtitle settings', () => {
let fixture: ComponentFixture<PlayerControlsComponent>;
let fake: ReturnType<typeof createFakeController>;
const setCapabilities = (
overrides: Partial<PlayerControlsCapabilities>
) => {
fake.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
...overrides,
});
};
const setState = (overrides: Partial<PlayerControlsState>) => {
fake.state.set({ ...createEmptyControlsState(), ...overrides });
};
const query = (selector: string) =>
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
const openSubtitleMenu = () => {
fixture.componentInstance.toggleMenu('subtitle');
fixture.detectChanges();
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
EMBEDDED_MPV: {
PLAYER: {
SUBTITLES: 'Subtitles',
SUBTITLES_OFF: 'Off',
LOAD_SUBTITLE_FILE: 'Load subtitle file…',
SUBTITLE_DELAY: 'Subtitle delay',
SUBTITLE_DELAY_DECREASE: 'Show subtitles earlier',
SUBTITLE_DELAY_INCREASE: 'Show subtitles later',
SUBTITLE_DELAY_RESET: 'Reset subtitle delay',
SUBTITLE_SIZE: 'Subtitle size',
SUBTITLE_COLOR: 'Subtitle color',
SUBTITLE_COLOR_DEFAULT: 'Default',
SUBTITLE_COLOR_WHITE: 'White',
SUBTITLE_COLOR_YELLOW: 'Yellow',
SUBTITLE_COLOR_CYAN: 'Cyan',
},
},
});
translate.use('en');
fake = createFakeController();
fixture = TestBed.createComponent(PlayerControlsComponent);
fixture.componentRef.setInput('controller', fake.controller);
fixture.detectChanges();
});
it('renders the subtitle button with zero tracks when external loading exists', () => {
expect(query('[aria-label="Subtitles"]')).toBeNull();
setCapabilities({ externalSubtitles: true });
fixture.detectChanges();
expect(query('[aria-label="Subtitles"]')).not.toBeNull();
openSubtitleMenu();
// No track list entries: no Off row without a selectable track…
expect(query('.player-controls__track--selected')).toBeNull();
// …but the load action is present.
const load = query('[data-test-id="player-controls-load-subtitle"]');
expect(load).not.toBeNull();
load?.click();
expect(fake.commands.addExternalSubtitleFile).toHaveBeenCalledTimes(1);
fixture.detectChanges();
// The pick closes the popover (a file dialog opens on top).
expect(
query('[data-test-id="player-controls-load-subtitle"]')
).toBeNull();
});
it('hides the load action, delay, and style sections without the capabilities', () => {
setCapabilities({ subtitles: true });
setState({
subtitleTracks: [{ id: 0, label: 'English', selected: false }],
});
fixture.detectChanges();
openSubtitleMenu();
expect(
query('[data-test-id="player-controls-load-subtitle"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-subtitle-delay"]')
).toBeNull();
expect(
query('[data-test-id="player-controls-subtitle-style"]')
).toBeNull();
});
it('steps, displays, and resets the subtitle delay', () => {
setCapabilities({ subtitles: true, subtitleDelay: true });
setState({
subtitleTracks: [{ id: 0, label: 'External', selected: true }],
subtitlesEnabled: true,
subtitleDelaySeconds: 0.5,
});
fixture.detectChanges();
openSubtitleMenu();
const section = query(
'[data-test-id="player-controls-subtitle-delay"]'
);
expect(section).not.toBeNull();
expect(
section?.querySelector('.player-controls__subtitle-delay-value')
?.textContent
).toContain('+0.5 s');
(
section?.querySelector(
'[aria-label="Show subtitles later"]'
) as HTMLButtonElement
).click();
expect(fake.commands.setSubtitleDelay).toHaveBeenCalledWith(1);
(
section?.querySelector(
'[aria-label="Show subtitles earlier"]'
) as HTMLButtonElement
).click();
expect(fake.commands.setSubtitleDelay).toHaveBeenCalledWith(0);
(
section?.querySelector(
'[aria-label="Reset subtitle delay"]'
) as HTMLButtonElement
).click();
expect(fake.commands.setSubtitleDelay).toHaveBeenLastCalledWith(0);
// The menu stays open for repeated adjustment.
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-subtitle-delay"]')
).not.toBeNull();
});
it('applies size and color presets through setSubtitleStyle', () => {
setCapabilities({ subtitles: true, subtitleStyle: true });
setState({
subtitleTracks: [{ id: 0, label: 'English', selected: true }],
subtitlesEnabled: true,
});
fixture.detectChanges();
openSubtitleMenu();
const style = query(
'[data-test-id="player-controls-subtitle-style"]'
) as HTMLElement;
const chips = Array.from(
style.querySelectorAll<HTMLButtonElement>(
'.player-controls__subtitle-chip'
)
);
const largeChip = chips.find((chip) =>
chip.textContent?.includes('150%')
);
largeChip?.click();
expect(fake.commands.setSubtitleStyle).toHaveBeenCalledWith({
sizePercent: 150,
color: null,
});
(
style.querySelector(
'[aria-label="Yellow"]'
) as HTMLButtonElement
).click();
expect(fake.commands.setSubtitleStyle).toHaveBeenLastCalledWith({
sizePercent: 100,
color: '#ffe94f',
});
});
it('guards the new commands behind their capabilities', () => {
const component = fixture.componentInstance;
component.loadExternalSubtitle();
component.subtitleSettings.adjustDelay(0.5);
component.subtitleSettings.resetDelay();
component.subtitleSettings.setSize(150);
component.subtitleSettings.setColor('#ffffff');
expect(fake.commands.addExternalSubtitleFile).not.toHaveBeenCalled();
expect(fake.commands.setSubtitleDelay).not.toHaveBeenCalled();
expect(fake.commands.setSubtitleStyle).not.toHaveBeenCalled();
});
it('clamps stepped delays to the supported window', () => {
setCapabilities({ subtitles: true, subtitleDelay: true });
setState({
subtitleTracks: [{ id: 0, label: 'External', selected: true }],
subtitleDelaySeconds: 60,
});
fixture.detectChanges();
fixture.componentInstance.subtitleSettings.adjustDelay(0.5);
expect(fake.commands.setSubtitleDelay).toHaveBeenCalledWith(60);
});
});
@@ -27,6 +27,9 @@ function createFakeController() {
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
@@ -24,6 +24,9 @@ function createFakeController() {
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
@@ -25,11 +25,18 @@ import { ControlsTimeline } from './controls-timeline';
import { ControlsVisibility } from './controls-visibility';
import { createControlsViewModel } from './controls-view-model';
import { ControlsVolume } from './controls-volume';
import { ControlsSubtitleSettings } from './controls-subtitle-settings';
import { formatTime, speedLabel } from './controls-format.utils';
import type {
PlayerController,
PlayerMediaTitle,
} from './player-controls.model';
import {
SUBTITLE_COLOR_PRESETS,
SUBTITLE_DELAY_STEP_SECONDS,
SUBTITLE_SIZE_PRESETS,
subtitleDelayLabel,
} from './subtitle-style';
@Component({
selector: 'app-player-controls',
@@ -81,6 +88,10 @@ export class PlayerControlsComponent implements OnDestroy {
},
this.host
);
readonly subtitleSettings = new ControlsSubtitleSettings({
controller: () => this.controller(),
revealSticky: () => this.reveal({ scheduleHide: false }),
});
readonly menuSelection = new ControlsMenuSelection({
commands: () => this.controller().commands,
menus: this.menus,
@@ -229,6 +240,10 @@ export class PlayerControlsComponent implements OnDestroy {
}
formatTime = formatTime;
speedLabel = speedLabel;
subtitleDelayLabel = subtitleDelayLabel;
readonly subtitleSizePresets = SUBTITLE_SIZE_PRESETS;
readonly subtitleColorPresets = SUBTITLE_COLOR_PRESETS;
readonly subtitleDelayStep = SUBTITLE_DELAY_STEP_SECONDS;
togglePlay(): void {
this.reveal();
if (!this.canTogglePlay()) {
@@ -307,6 +322,13 @@ export class PlayerControlsComponent implements OnDestroy {
this.menus.toggle(menu);
this.reveal();
}
loadExternalSubtitle(): void {
if (!this.capabilities().externalSubtitles) {
return;
}
this.menuSelection.externalSubtitle();
}
toggleRecording(): void {
if (!this.canRecord()) {
return;
@@ -1,4 +1,5 @@
import type { Signal } from '@angular/core';
import type { PlayerSubtitleStyle } from '@iptvnator/shared/interfaces';
export type PlayerStatus =
| 'idle'
@@ -14,6 +15,12 @@ export interface PlayerControlsCapabilities {
volume: boolean;
audioTracks: boolean;
subtitles: boolean;
/** Loading an external subtitle file (.srt/.vtt, mpv also .ass). */
externalSubtitles: boolean;
/** Adjusting the subtitle timing offset for the current playback. */
subtitleDelay: boolean;
/** Adjusting subtitle text size and color. */
subtitleStyle: boolean;
/** Manifest-driven: true only when the source exposes >1 video rendition. */
qualityLevels: boolean;
playbackSpeed: boolean;
@@ -24,6 +31,10 @@ export interface PlayerControlsCapabilities {
seriesNavigation: boolean;
}
// Canonical shape lives in @iptvnator/shared/interfaces so the Electron main
// process validates IPC payloads against the identical contract.
export type { PlayerSubtitleStyle };
export interface PlayerTrack {
id: number;
label: string; // adapter pre-computes the display label
@@ -77,6 +88,10 @@ export interface PlayerControlsState {
subtitleTracks: PlayerTrack[];
/** True when a subtitle track is active (selected id !== null/off). */
subtitlesEnabled: boolean;
/** Current subtitle timing offset; positive shows subtitles later. */
subtitleDelaySeconds: number;
/** Current subtitle presentation preferences. */
subtitleStyle: PlayerSubtitleStyle;
/**
* Per-level quality options ("1080p", …). A level reports `selected` only
* while a manual selection is active; with ABR on, none is selected.
@@ -103,6 +118,10 @@ export interface PlayerControlsCommands {
setVolume(value: number): void; // 0..1
setAudioTrack(id: number): void;
setSubtitleTrack(id: number): void; // -1 = off
/** Opens the engine's subtitle file picker; loads + selects the file. */
addExternalSubtitleFile(): void;
setSubtitleDelay(seconds: number): void;
setSubtitleStyle(style: PlayerSubtitleStyle): void;
setQualityLevel(id: number): void; // AUTO_QUALITY_LEVEL_ID (-1) = auto
setPlaybackSpeed(speed: number): void;
setAspectRatio(value: string): void;
@@ -0,0 +1,104 @@
import {
DEFAULT_SUBTITLE_STYLE,
SUBTITLE_STYLE_STORAGE_KEY,
clampSubtitleDelay,
isDefaultSubtitleStyle,
normalizeSubtitleStyle,
persistSubtitleStyle,
readStoredSubtitleStyle,
subtitleDelayLabel,
} from './subtitle-style';
describe('subtitle-style', () => {
afterEach(() => {
localStorage.clear();
});
describe('clampSubtitleDelay', () => {
it('clamps to the ±60 s window and rejects non-finite values', () => {
expect(clampSubtitleDelay(1.5)).toBe(1.5);
expect(clampSubtitleDelay(500)).toBe(60);
expect(clampSubtitleDelay(-500)).toBe(-60);
expect(clampSubtitleDelay(Number.NaN)).toBe(0);
expect(clampSubtitleDelay(Number.POSITIVE_INFINITY)).toBe(0);
});
it('rounds away float drift from repeated 0.5 steps', () => {
expect(clampSubtitleDelay(0.1 + 0.2)).toBe(0.3);
});
});
describe('normalizeSubtitleStyle', () => {
it('returns the default for junk input', () => {
expect(normalizeSubtitleStyle(null)).toEqual(
DEFAULT_SUBTITLE_STYLE
);
expect(normalizeSubtitleStyle('big')).toEqual(
DEFAULT_SUBTITLE_STYLE
);
expect(
normalizeSubtitleStyle({ sizePercent: 'x', color: 42 })
).toEqual(DEFAULT_SUBTITLE_STYLE);
});
it('clamps the size and validates the color format', () => {
expect(
normalizeSubtitleStyle({ sizePercent: 1000, color: '#FFE94F' })
).toEqual({ sizePercent: 400, color: '#ffe94f' });
expect(
normalizeSubtitleStyle({ sizePercent: 5, color: 'red' })
).toEqual({ sizePercent: 25, color: null });
});
});
describe('persistence', () => {
it('round-trips a non-default style through localStorage', () => {
persistSubtitleStyle({ sizePercent: 150, color: '#ffffff' });
expect(readStoredSubtitleStyle()).toEqual({
sizePercent: 150,
color: '#ffffff',
});
});
it('removes the stored value when the style returns to default', () => {
persistSubtitleStyle({ sizePercent: 150, color: null });
persistSubtitleStyle({ ...DEFAULT_SUBTITLE_STYLE });
expect(
localStorage.getItem(SUBTITLE_STYLE_STORAGE_KEY)
).toBeNull();
expect(readStoredSubtitleStyle()).toEqual(DEFAULT_SUBTITLE_STYLE);
});
it('falls back to the default for corrupted stored values', () => {
localStorage.setItem(SUBTITLE_STYLE_STORAGE_KEY, '{not json');
expect(readStoredSubtitleStyle()).toEqual(DEFAULT_SUBTITLE_STYLE);
});
});
describe('isDefaultSubtitleStyle', () => {
it('detects the default and non-default styles', () => {
expect(isDefaultSubtitleStyle({ ...DEFAULT_SUBTITLE_STYLE })).toBe(
true
);
expect(
isDefaultSubtitleStyle({ sizePercent: 100, color: '#ffffff' })
).toBe(false);
expect(
isDefaultSubtitleStyle({ sizePercent: 125, color: null })
).toBe(false);
});
});
describe('subtitleDelayLabel', () => {
it('formats zero, positive, and negative delays', () => {
expect(subtitleDelayLabel(0)).toBe('0 s');
expect(subtitleDelayLabel(0.5)).toBe('+0.5 s');
expect(subtitleDelayLabel(-1.5)).toBe('−1.5 s');
});
it('never shows a signed negative zero for sub-tenth values', () => {
expect(subtitleDelayLabel(0.02)).toBe('0 s');
expect(subtitleDelayLabel(-0.04)).toBe('0 s');
});
});
});
@@ -0,0 +1,98 @@
import {
DEFAULT_SUBTITLE_STYLE,
type PlayerSubtitleStyle,
isDefaultSubtitleStyle,
normalizeSubtitleStyle,
} from '@iptvnator/shared/interfaces';
import type { PlayerPreset } from './player-controls.model';
/**
* UI-side subtitle presentation preferences. The canonical shape and the
* clamp/normalize rules live in `@iptvnator/shared/interfaces`
* (`subtitle-style.util.ts`) so the Electron main process re-validates IPC
* input with the exact same implementation; this file adds only what the
* controls UI needs — presets, the delay step, persistence, and labels.
*
* The style (size/color) persists across sessions through the same
* localStorage mechanism the players already use for the shared 'volume' key,
* so every engine adapter reads one source of truth. The delay and any loaded
* external subtitle file are deliberately per-session: they correct one
* specific stream, not a user preference.
*/
export {
DEFAULT_SUBTITLE_STYLE,
SUBTITLE_DELAY_LIMIT_SECONDS,
SUBTITLE_SIZE_MAX_PERCENT,
SUBTITLE_SIZE_MIN_PERCENT,
clampSubtitleDelay,
isDefaultSubtitleStyle,
normalizeSubtitleStyle,
} from '@iptvnator/shared/interfaces';
export const SUBTITLE_STYLE_STORAGE_KEY = 'subtitleStyle';
export const SUBTITLE_SIZE_PRESETS: ReadonlyArray<PlayerPreset<number>> = [
{ value: 75, label: '75%' },
{ value: 100, label: '100%' },
{ value: 125, label: '125%' },
{ value: 150, label: '150%' },
{ value: 200, label: '200%' },
];
/** Swatch values; null = engine default. Labels are translation keys. */
export const SUBTITLE_COLOR_PRESETS: ReadonlyArray<PlayerPreset<string | null>> =
[
{ value: null, label: 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR_DEFAULT' },
{ value: '#ffffff', label: 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR_WHITE' },
{
value: '#ffe94f',
label: 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR_YELLOW',
},
{ value: '#7fdbff', label: 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR_CYAN' },
];
export const SUBTITLE_DELAY_STEP_SECONDS = 0.5;
export function readStoredSubtitleStyle(): PlayerSubtitleStyle {
try {
const raw = localStorage.getItem(SUBTITLE_STYLE_STORAGE_KEY);
if (!raw) {
return { ...DEFAULT_SUBTITLE_STYLE };
}
return normalizeSubtitleStyle(JSON.parse(raw));
} catch {
return { ...DEFAULT_SUBTITLE_STYLE };
}
}
export function persistSubtitleStyle(style: PlayerSubtitleStyle): void {
try {
const normalized = normalizeSubtitleStyle(style);
if (isDefaultSubtitleStyle(normalized)) {
localStorage.removeItem(SUBTITLE_STYLE_STORAGE_KEY);
return;
}
localStorage.setItem(
SUBTITLE_STYLE_STORAGE_KEY,
JSON.stringify(normalized)
);
} catch {
// Storage may be unavailable (private mode); the style stays session-local.
}
}
/** "+0.5 s" / "−1.5 s" / "0 s" display label for the delay row. */
export function subtitleDelayLabel(seconds: number): string {
if (!Number.isFinite(seconds)) {
return '0 s';
}
const rounded = Math.round(seconds * 10) / 10;
// Derive the sign AFTER rounding: 0.02 rounds to 0 and must render as
// "0 s", not "−0.0 s".
if (rounded === 0) {
return '0 s';
}
const magnitude = Math.abs(rounded).toFixed(1);
return `${rounded > 0 ? '+' : '−'}${magnitude} s`;
}
@@ -0,0 +1,107 @@
import { DEFAULT_SUBTITLE_STYLE } from './subtitle-style';
import { WebVideoControlsAdapter } from './web-video-controls.adapter';
function createVideo(): HTMLVideoElement {
const video = document.createElement('video');
Object.defineProperties(video, {
duration: { configurable: true, value: 120 },
readyState: { configurable: true, value: 4 },
networkState: { configurable: true, value: 1 },
paused: { configurable: true, value: true },
seekable: { configurable: true, value: { length: 1 } },
});
return video;
}
describe('WebVideoControlsAdapter subtitle settings', () => {
let adapter: WebVideoControlsAdapter;
let video: HTMLVideoElement;
beforeEach(() => {
adapter = new WebVideoControlsAdapter();
video = createVideo();
});
afterEach(() => {
adapter.detach();
});
it('advertises no subtitle-settings capability without the options', () => {
adapter.attach(video, {});
const capabilities = adapter.capabilities();
expect(capabilities.externalSubtitles).toBe(false);
expect(capabilities.subtitleDelay).toBe(false);
expect(capabilities.subtitleStyle).toBe(false);
expect(adapter.state().subtitleDelaySeconds).toBe(0);
expect(adapter.state().subtitleStyle).toEqual(DEFAULT_SUBTITLE_STYLE);
});
it('derives the capabilities from the injected options', () => {
const canAdjustSubtitleDelay = jest.fn().mockReturnValue(false);
adapter.attach(video, {
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
canAdjustSubtitleDelay,
setSubtitleStyle: jest.fn(),
});
expect(adapter.capabilities().externalSubtitles).toBe(true);
expect(adapter.capabilities().subtitleStyle).toBe(true);
// The runtime gate keeps delay off until a file is loaded.
expect(adapter.capabilities().subtitleDelay).toBe(false);
canAdjustSubtitleDelay.mockReturnValue(true);
adapter.refresh();
expect(adapter.capabilities().subtitleDelay).toBe(true);
});
it('projects delay and style state from the engine getters', () => {
adapter.attach(video, {
getSubtitleDelay: () => 1.5,
getSubtitleStyle: () => ({ sizePercent: 150, color: '#ffffff' }),
});
expect(adapter.state().subtitleDelaySeconds).toBe(1.5);
expect(adapter.state().subtitleStyle).toEqual({
sizePercent: 150,
color: '#ffffff',
});
});
it('delegates the commands and refreshes after synchronous setters', () => {
const addExternalSubtitleFile = jest.fn();
const setSubtitleDelay = jest.fn();
const setSubtitleStyle = jest.fn();
const getSubtitleDelay = jest.fn().mockReturnValue(0);
adapter.attach(video, {
addExternalSubtitleFile,
getSubtitleDelay,
setSubtitleDelay,
setSubtitleStyle,
});
adapter.commands.addExternalSubtitleFile();
expect(addExternalSubtitleFile).toHaveBeenCalledTimes(1);
getSubtitleDelay.mockReturnValue(2);
adapter.commands.setSubtitleDelay(2);
expect(setSubtitleDelay).toHaveBeenCalledWith(2);
expect(adapter.state().subtitleDelaySeconds).toBe(2);
adapter.commands.setSubtitleStyle({ sizePercent: 125, color: null });
expect(setSubtitleStyle).toHaveBeenCalledWith({
sizePercent: 125,
color: null,
});
});
it('contains a throwing picker without breaking the command surface', () => {
adapter.attach(video, {
addExternalSubtitleFile: () => {
throw new Error('no dialog');
},
});
expect(() => adapter.commands.addExternalSubtitleFile()).not.toThrow();
});
});
@@ -10,8 +10,10 @@ import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
PlayerSubtitleStyle,
PlayerTrack,
} from './player-controls.model';
import { DEFAULT_SUBTITLE_STYLE } from './subtitle-style';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import {
applyTrackSelection,
@@ -40,6 +42,14 @@ export interface WebVideoControlsOptions extends WebVideoMetadataOptions {
setAudioTrack?: (id: number) => void | Promise<void>;
getSubtitleTracks?: () => PlayerTrack[];
setSubtitleTrack?: (id: number) => void | Promise<void>;
/** Opens the engine's subtitle file picker; loads + selects the pick. */
addExternalSubtitleFile?: () => void;
getSubtitleDelay?: () => number;
setSubtitleDelay?: (seconds: number) => void | Promise<void>;
/** Runtime gate for the delay UI (e.g. only once a file is loaded). */
canAdjustSubtitleDelay?: () => boolean;
getSubtitleStyle?: () => PlayerSubtitleStyle;
setSubtitleStyle?: (style: PlayerSubtitleStyle) => void | Promise<void>;
getQualityLevels?: () => PlayerTrack[];
setQualityLevel?: (id: number) => void | Promise<void>;
isAutoQualityEnabled?: () => boolean;
@@ -107,6 +117,12 @@ export class WebVideoControlsAdapter implements PlayerController {
fullscreen: true,
audioTracks: hasAudioTracks,
subtitles: hasSubtitles,
externalSubtitles:
typeof this.opts.addExternalSubtitleFile === 'function',
subtitleDelay:
typeof this.opts.setSubtitleDelay === 'function' &&
(this.opts.canAdjustSubtitleDelay?.() ?? true),
subtitleStyle: typeof this.opts.setSubtitleStyle === 'function',
qualityLevels: hasQualityLevels,
aspectRatio: false,
recording: false,
@@ -147,6 +163,9 @@ export class WebVideoControlsAdapter implements PlayerController {
audioTracks,
subtitleTracks,
subtitlesEnabled: subtitleTracks.some((track) => track.selected),
subtitleDelaySeconds: this.opts.getSubtitleDelay?.() ?? 0,
subtitleStyle:
this.opts.getSubtitleStyle?.() ?? DEFAULT_SUBTITLE_STYLE,
qualityLevels,
qualityAutoEnabled: this.opts.isAutoQualityEnabled?.() ?? true,
playbackSpeed: video?.playbackRate ?? 1,
@@ -183,6 +202,23 @@ export class WebVideoControlsAdapter implements PlayerController {
applyTrackSelection(this.opts.setSubtitleTrack, id, () =>
this.refresh()
),
addExternalSubtitleFile: () => {
try {
// The picker refreshes through its own callback once the
// asynchronous file read lands.
this.opts.addExternalSubtitleFile?.();
} catch {
// A failed picker leaves the current tracks untouched.
}
},
setSubtitleDelay: (seconds) =>
applyTrackSelection(this.opts.setSubtitleDelay, seconds, () =>
this.refresh()
),
setSubtitleStyle: (style) =>
applyTrackSelection(this.opts.setSubtitleStyle, style, () =>
this.refresh()
),
setQualityLevel: (id) =>
applyTrackSelection(this.opts.setQualityLevel, id, () =>
this.refresh()
@@ -96,9 +96,9 @@ export function applyVideoSpeed(
}
}
export function applyTrackSelection(
setter: ((id: number) => void | Promise<void>) | undefined,
id: number,
export function applyTrackSelection<T = number>(
setter: ((value: T) => void | Promise<void>) | undefined,
id: T,
refresh: () => void
): void {
if (!setter) {
@@ -0,0 +1,234 @@
import {
decodeExternalSubtitleBytes,
detectExternalSubtitleFormat,
parseExternalSubtitleCues,
} from './external-subtitle-cues.util';
describe('external-subtitle-cues.util', () => {
describe('detectExternalSubtitleFormat', () => {
it('detects srt and vtt by extension, case-insensitively', () => {
expect(detectExternalSubtitleFormat('movie.srt')).toBe('srt');
expect(detectExternalSubtitleFormat('Movie.SRT')).toBe('srt');
expect(detectExternalSubtitleFormat('movie.en.vtt')).toBe('vtt');
});
it('rejects unsupported extensions', () => {
expect(detectExternalSubtitleFormat('movie.ass')).toBeNull();
expect(detectExternalSubtitleFormat('movie.sub')).toBeNull();
expect(detectExternalSubtitleFormat('movie')).toBeNull();
});
});
describe('decodeExternalSubtitleBytes', () => {
const toBuffer = (bytes: number[]): ArrayBuffer =>
Uint8Array.from(bytes).buffer;
it('decodes valid UTF-8 as-is', () => {
const utf8 = new TextEncoder().encode('Привет\nmonde');
expect(decodeExternalSubtitleBytes(utf8.buffer)).toBe(
'Привет\nmonde'
);
});
it('decodes Cyrillic Windows-1251 bytes (high-byte-heavy text)', () => {
// "Привет мир" in CP1251: every letter is a high byte.
const cp1251 = [
0xcf, 0xf0, 0xe8, 0xe2, 0xe5, 0xf2, 0x20, 0xec, 0xe8, 0xf0,
];
expect(decodeExternalSubtitleBytes(toBuffer(cp1251))).toBe(
'Привет мир'
);
});
it('detects CP1251 in a short-dialogue SRT where ASCII timing bytes dominate', () => {
// "Да" / "Нет" in CP1251 among full SRT timing scaffolding: only
// 5 of ~70 bytes are high, but ALL letter bytes are — the
// discriminator must ignore the timing lines.
const ascii = (text: string) =>
Array.from(text).map((c) => c.charCodeAt(0));
const srt = [
...ascii('1\n00:00:01,000 --> 00:00:02,000\n'),
0xc4, 0xe0, // Да
...ascii('\n\n2\n00:00:03,000 --> 00:00:04,000\n'),
0xcd, 0xe5, 0xf2, // Нет
...ascii('\n'),
];
expect(decodeExternalSubtitleBytes(toBuffer(srt))).toContain('Да');
expect(decodeExternalSubtitleBytes(toBuffer(srt))).toContain(
'Нет'
);
});
it('keeps accent-dense CP1252 words Latin ("Été" must not become Cyrillic)', () => {
// É=0xC9, t, é=0xE9 — more accented than ASCII letters, so a
// byte-ratio heuristic flips to CP1251 and renders "Йtй". The
// mixed-script plausibility check must keep this Windows-1252.
const cp1252 = [
0xc9, 0x74, 0xe9, // Été
...Array.from(' 1\n00:00:01,000 --> 00:00:02,000\n').map((c) =>
c.charCodeAt(0)
),
0xc0, // À
...Array.from(' table !').map((c) => c.charCodeAt(0)),
];
const decoded = decodeExternalSubtitleBytes(toBuffer(cp1252));
expect(decoded).toContain('Été');
expect(decoded).toContain('À table');
});
it('keeps a minimal isolated-accent CP1252 caption Latin ("À la")', () => {
// One lone Cyrillic-looking letter and two ASCII letters passed
// the earlier share guard; single-letter words must carry no
// script evidence at all.
const cp1252 = [
0xc0, // À
...Array.from(
' la\n1\n00:00:01,000 --> 00:00:02,000\n'
).map((c) => c.charCodeAt(0)),
];
expect(decodeExternalSubtitleBytes(toBuffer(cp1252))).toContain(
'À la'
);
});
it('keeps an isolated accented CP1252 word Latin ("À table")', () => {
// À=0xC0 decodes under CP1251 to the pure-Cyrillic one-letter
// word "А"; without the letter-share guard that single vote
// flips the whole file to Cyrillic.
const cp1252 = [
0xc0, // À
...Array.from(
' table !\n1\n00:00:01,000 --> 00:00:02,000\n'
).map((c) => c.charCodeAt(0)),
];
expect(decodeExternalSubtitleBytes(toBuffer(cp1252))).toContain(
'À table'
);
});
it('decodes mostly-ASCII Windows-1252 bytes (sparse accents)', () => {
// "resume: cafe" with two accented letters among ASCII.
const cp1252 = [
...Array.from('r').map((c) => c.charCodeAt(0)),
0xe9, // é
...Array.from('sum').map((c) => c.charCodeAt(0)),
0xe9, // é
...Array.from(': cafe and plain ascii words').map((c) =>
c.charCodeAt(0)
),
];
expect(decodeExternalSubtitleBytes(toBuffer(cp1252))).toBe(
'résumé: cafe and plain ascii words'
);
});
it('honors a UTF-16LE byte-order mark', () => {
const text = '1\n00:00:01,000';
const bytes: number[] = [0xff, 0xfe];
for (const char of text) {
const code = char.charCodeAt(0);
bytes.push(code & 0xff, code >> 8);
}
expect(decodeExternalSubtitleBytes(toBuffer(bytes))).toBe(text);
});
});
describe('parseExternalSubtitleCues', () => {
it('parses a standard SRT file', () => {
const content = [
'1',
'00:00:01,000 --> 00:00:03,500',
'First line',
'second row',
'',
'2',
'00:01:00,250 --> 00:01:02,000',
'Later',
'',
].join('\r\n');
expect(
parseExternalSubtitleCues({ format: 'srt', content })
).toEqual([
{
startSeconds: 1,
endSeconds: 3.5,
text: 'First line\nsecond row',
},
{ startSeconds: 60.25, endSeconds: 62, text: 'Later' },
]);
});
it('parses a WebVTT file with header, notes, and settings', () => {
const content = [
'WEBVTT',
'',
'NOTE this is a comment',
'',
'intro',
'00:05.000 --> 00:07.000 align:start line:90%',
'Short-form timestamps work',
'',
'01:00:00.000 --> 01:00:04.000',
'<i>Styled</i> text survives',
].join('\n');
expect(
parseExternalSubtitleCues({ format: 'vtt', content })
).toEqual([
{
startSeconds: 5,
endSeconds: 7,
text: 'Short-form timestamps work',
},
{
startSeconds: 3600,
endSeconds: 3604,
text: '<i>Styled</i> text survives',
},
]);
});
it('skips malformed blocks instead of failing the file', () => {
const content = [
'1',
'00:00:05,000 --> 00:00:01,000',
'End before start is dropped',
'',
'2',
'00:00:10,000 --> 00:00:12,000',
'Valid',
'',
'not a timing line at all',
'trailing junk',
].join('\n');
expect(
parseExternalSubtitleCues({ format: 'srt', content })
).toEqual([
{ startSeconds: 10, endSeconds: 12, text: 'Valid' },
]);
});
it('sorts cues by start time and drops empty-text cues', () => {
const content = [
'00:00:30.000 --> 00:00:31.000',
'Second',
'',
'00:00:02.000 --> 00:00:03.000',
'First',
'',
'00:00:40.000 --> 00:00:41.000',
'<00:00:40.500>',
'',
].join('\n');
expect(
parseExternalSubtitleCues({ format: 'vtt', content })
).toEqual([
{ startSeconds: 2, endSeconds: 3, text: 'First' },
{ startSeconds: 30, endSeconds: 31, text: 'Second' },
]);
});
});
});
@@ -0,0 +1,192 @@
/**
* Minimal SRT/WebVTT cue extraction for user-supplied subtitle files.
*
* The web engines render external subtitles through native `TextTrack`s, so
* only start/end/text are needed — positioning and styling blocks are
* intentionally dropped. ASS/SSA is NOT handled here: rendering it faithfully
* needs libass, so the web engines simply do not accept those files (the
* Embedded MPV engine plays them natively instead).
*/
export interface ParsedSubtitleCue {
startSeconds: number;
endSeconds: number;
text: string;
}
export type ExternalSubtitleFormat = 'srt' | 'vtt';
export interface ExternalSubtitleFile {
/** Display name, usually the picked file's name. */
name: string;
format: ExternalSubtitleFormat;
content: string;
}
/** Extensions the web engines accept in their file picker. */
export const WEB_SUBTITLE_FILE_EXTENSIONS = ['.srt', '.vtt'] as const;
export function detectExternalSubtitleFormat(
fileName: string
): ExternalSubtitleFormat | null {
const normalized = fileName.trim().toLowerCase();
if (normalized.endsWith('.srt')) {
return 'srt';
}
if (normalized.endsWith('.vtt')) {
return 'vtt';
}
return null;
}
/**
* Decodes a picked subtitle file's bytes. `Blob.text()` is strictly UTF-8 with
* silent U+FFFD substitution, which turns the still-common legacy-encoded SRT
* files (Windows-1251 Cyrillic, Windows-1252 Western European) and Windows'
* UTF-16 saves into mojibake that parses "successfully". Best-effort order:
* UTF-16 BOMs, then strict UTF-8, then a single-byte fallback chosen by the
* plausibility of the decoded text (see {@link chooseLegacySingleByteDecode}).
*/
export function decodeExternalSubtitleBytes(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
if (bytes.length >= 2) {
if (bytes[0] === 0xff && bytes[1] === 0xfe) {
return new TextDecoder('utf-16le').decode(buffer);
}
if (bytes[0] === 0xfe && bytes[1] === 0xff) {
return new TextDecoder('utf-16be').decode(buffer);
}
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buffer);
} catch {
// Not valid UTF-8: a legacy single-byte encoding.
}
try {
return chooseLegacySingleByteDecode(buffer);
} catch {
// Runtime without legacy decoders: non-fatal UTF-8 is the last resort.
return new TextDecoder().decode(buffer);
}
}
/**
* Picks between the two dominant legacy encodings by the PLAUSIBILITY of the
* Windows-1251 candidate, not a byte ratio (a ratio drowns short dialogue in
* ASCII timing bytes one way, and over-weights accent-dense Latin words like
* "Été" the other way). CP1251 maps every high letter byte into the Cyrillic
* block, so a genuinely Cyrillic file decodes into pure-Cyrillic words, while
* misread Latin text decodes into words that MIX Cyrillic and ASCII letters
* ("était" → "йtait") — a shape real subtitles never contain.
*/
function chooseLegacySingleByteDecode(buffer: ArrayBuffer): string {
const decoded1251 = new TextDecoder('windows-1251').decode(buffer);
let cyrillicLetters = 0;
let mixedLetters = 0;
let asciiLetters = 0;
for (const word of decoded1251.split(/[^\p{L}]+/u)) {
if (!word) {
continue;
}
if (!/[\u0400-\u04ff]/.test(word)) {
asciiLetters += word.length;
} else if (/[A-Za-z]/.test(word)) {
mixedLetters += word.length;
} else if (word.length >= 2) {
// A single letter carries no trustworthy script information: an
// isolated CP1252 accent ("\u00c0"/"\u00c9") decodes to a lone Cyrillic
// letter and must not count as Cyrillic evidence, while genuine
// Cyrillic dialogue always contains multi-letter words.
cyrillicLetters += word.length;
}
}
// Two guards on top of that: mixed-script words are strong evidence of
// misread Latin text ("\u00e9tait" \u2192 "\u0439tait"), and Cyrillic must carry a
// meaningful share of all letters before 1251 wins \u2014 genuinely Cyrillic
// dialogue dominates its own letter count even with embedded Latin names.
// A file whose only high bytes are one or two isolated letters is
// fundamentally ambiguous; defaulting it to 1252 is the best effort.
const plausiblyCyrillic =
cyrillicLetters > mixedLetters * 2 &&
cyrillicLetters * 4 > asciiLetters;
return plausiblyCyrillic
? decoded1251
: new TextDecoder('windows-1252').decode(buffer);
}
// SRT uses "00:00:01,500"; VTT uses "00:00:01.500" and allows a missing hour
// part ("01:23.456"). One pattern covers both.
const TIMESTAMP_PATTERN =
/(?:(\d{1,3}):)?(\d{1,2}):(\d{1,2})[.,](\d{1,3})/;
const TIMING_LINE_PATTERN = new RegExp(
`^\\s*(${TIMESTAMP_PATTERN.source})\\s*-->\\s*(${TIMESTAMP_PATTERN.source})`
);
function parseTimestamp(value: string): number | null {
const match = TIMESTAMP_PATTERN.exec(value.trim());
if (!match || match[0] !== value.trim()) {
return null;
}
const hours = match[1] !== undefined ? Number(match[1]) : 0;
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const millis = Number(match[4].padEnd(3, '0'));
return hours * 3600 + minutes * 60 + seconds + millis / 1000;
}
function stripVttMarkup(text: string): string {
// Remove inline cue timestamps ("karaoke" tags) but keep the text; the
// browser renders remaining <i>/<b>-style tags itself inside VTTCue.
return text.replace(/<\d{1,3}:\d{2}:\d{2}[.,]\d{1,3}>/g, '');
}
/**
* Parses SRT or VTT content into cues, skipping malformed blocks instead of
* failing the whole file. Returns cues sorted by start time.
*/
export function parseExternalSubtitleCues(
file: Pick<ExternalSubtitleFile, 'format' | 'content'>
): ParsedSubtitleCue[] {
const lines = file.content.replace(/^\uFEFF/, '').split(/\r\n|\r|\n/);
const cues: ParsedSubtitleCue[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
const timing = TIMING_LINE_PATTERN.exec(line);
if (!timing) {
index += 1;
continue;
}
const startSeconds = parseTimestamp(timing[1]);
const endSeconds = parseTimestamp(timing[6]);
index += 1;
const textLines: string[] = [];
while (index < lines.length && lines[index].trim() !== '') {
textLines.push(lines[index]);
index += 1;
}
if (
startSeconds === null ||
endSeconds === null ||
endSeconds <= startSeconds ||
textLines.length === 0
) {
continue;
}
cues.push({
startSeconds,
endSeconds,
text: stripVttMarkup(textLines.join('\n')).trim(),
});
}
return cues
.filter((cue) => cue.text.length > 0)
.sort((a, b) => a.startSeconds - b.startSeconds);
}
@@ -0,0 +1,228 @@
import {
EXTERNAL_SUBTITLE_TRACK_ID_BASE,
WebVideoExternalSubtitles,
} from './web-video-external-subtitles';
import type { ExternalSubtitleFile } from './external-subtitle-cues.util';
class FakeVTTCue {
constructor(
public startTime: number,
public endTime: number,
public text: string
) {}
}
class FakeTextTrack {
kind = 'subtitles';
mode: TextTrackMode = 'hidden';
readonly added: FakeVTTCue[] = [];
constructor(public label: string) {}
addCue(cue: FakeVTTCue): void {
this.added.push(cue);
}
removeCue(cue: FakeVTTCue): void {
const index = this.added.indexOf(cue);
if (index >= 0) {
this.added.splice(index, 1);
}
}
}
const SRT_FILE: ExternalSubtitleFile = {
name: 'movie.srt',
format: 'srt',
content: [
'1',
'00:00:01,000 --> 00:00:03,000',
'Hello',
'',
'2',
'00:00:10,000 --> 00:00:12,000',
'World',
'',
].join('\n'),
};
describe('WebVideoExternalSubtitles', () => {
let video: { addTextTrack: jest.Mock };
let deselectEngineSubtitles: jest.Mock;
let refresh: jest.Mock;
let session: WebVideoExternalSubtitles;
beforeEach(() => {
(globalThis as { VTTCue?: unknown }).VTTCue = FakeVTTCue;
video = {
addTextTrack: jest.fn(
(_kind: string, label: string) => new FakeTextTrack(label)
),
};
deselectEngineSubtitles = jest.fn();
refresh = jest.fn();
session = new WebVideoExternalSubtitles({
getVideo: () => video as unknown as HTMLVideoElement,
deselectEngineSubtitles,
refresh,
});
});
afterEach(() => {
delete (globalThis as { VTTCue?: unknown }).VTTCue;
});
function lastTrack(): FakeTextTrack {
return video.addTextTrack.mock.results.at(-1)?.value as FakeTextTrack;
}
it('creates a native track, adds cues, and selects the file', () => {
expect(session.addFromFile(SRT_FILE)).toBe(true);
const track = lastTrack();
expect(video.addTextTrack).toHaveBeenCalledWith(
'subtitles',
'movie.srt'
);
expect(track.mode).toBe('showing');
expect(track.added.map((cue) => [cue.startTime, cue.endTime])).toEqual([
[1, 3],
[10, 12],
]);
expect(deselectEngineSubtitles).toHaveBeenCalled();
expect(session.getTracks()).toEqual([
{
id: EXTERNAL_SUBTITLE_TRACK_ID_BASE,
label: 'movie.srt',
selected: true,
},
]);
expect(session.ownsTrack(track as unknown as TextTrack)).toBe(true);
});
it('rejects files that yield no usable cue', () => {
expect(
session.addFromFile({
name: 'broken.srt',
format: 'srt',
content: 'no cues here',
})
).toBe(false);
expect(session.hasTracks()).toBe(false);
});
it('re-times owned cues when the delay changes and keeps originals exact', () => {
session.addFromFile(SRT_FILE);
const track = lastTrack();
session.setDelay(2.5);
expect(track.added.map((cue) => [cue.startTime, cue.endTime])).toEqual([
[3.5, 5.5],
[12.5, 14.5],
]);
// Negative delay keeps real cue times: negative values are legal and
// simply never active. Clamping to [0, ~0] would stack every pre-roll
// cue simultaneously at t=0.
session.setDelay(-2);
expect(track.added[0].startTime).toBe(-1);
expect(track.added[0].endTime).toBe(1);
session.setDelay(0);
expect(track.added.map((cue) => [cue.startTime, cue.endTime])).toEqual([
[1, 3],
[10, 12],
]);
});
it('keeps exactly one external track showing and deselects on demand', () => {
session.addFromFile(SRT_FILE);
const first = lastTrack();
session.addFromFile({ ...SRT_FILE, name: 'other.srt' });
const second = lastTrack();
expect(first.mode).toBe('hidden');
expect(second.mode).toBe('showing');
session.select(EXTERNAL_SUBTITLE_TRACK_ID_BASE);
expect(first.mode).toBe('showing');
expect(second.mode).toBe('hidden');
session.deselectAll();
expect(first.mode).toBe('hidden');
expect(second.mode).toBe('hidden');
});
it('clear() disables tracks, removes cues, and resets the delay', () => {
session.addFromFile(SRT_FILE);
const track = lastTrack();
session.setDelay(1);
session.clear();
expect(track.mode).toBe('disabled');
expect(track.added).toHaveLength(0);
expect(session.hasTracks()).toBe(false);
expect(session.getDelay()).toBe(0);
// addTextTrack tracks cannot leave the element: ownership must
// survive clear() so the native enumeration keeps excluding them
// instead of listing ghost tracks on the next source.
expect(session.ownsTrack(track as unknown as TextTrack)).toBe(true);
});
it('keeps the external track showing when engine deselect disables all tracks (hls.js)', () => {
// hls.js reacts to `subtitleTrack = -1` by disabling every
// subtitle-kind TextTrack on the element. select() must deselect the
// engine BEFORE setting its own modes so its writes win.
deselectEngineSubtitles.mockImplementation(() => {
for (const result of video.addTextTrack.mock.results) {
(result.value as FakeTextTrack).mode = 'disabled';
}
});
expect(session.addFromFile(SRT_FILE)).toBe(true);
expect(lastTrack().mode).toBe('showing');
});
it('reports selection separately from loaded files', () => {
session.addFromFile(SRT_FILE);
expect(session.hasTracks()).toBe(true);
expect(session.hasSelectedTrack()).toBe(true);
session.deselectAll();
expect(session.hasTracks()).toBe(true);
expect(session.hasSelectedTrack()).toBe(false);
});
it('silences and retains ownership of a track whose attach fails mid-file', () => {
const failingTrack = new FakeTextTrack('broken.srt');
let added = 0;
failingTrack.addCue = (cue: FakeVTTCue) => {
added += 1;
if (added > 1) {
throw new Error('addCue rejected');
}
failingTrack.added.push(cue);
};
video.addTextTrack.mockReturnValueOnce(failingTrack);
expect(
session.addFromFile({ ...SRT_FILE, name: 'broken.srt' })
).toBe(false);
expect(session.hasTracks()).toBe(false);
// The half-populated track is silenced, emptied, and stays owned so
// the native enumeration cannot surface it as a phantom engine track.
expect(failingTrack.mode).toBe('disabled');
expect(failingTrack.added).toHaveLength(0);
expect(
session.ownsTrack(failingTrack as unknown as TextTrack)
).toBe(true);
});
it('fails closed when the runtime lacks addTextTrack or VTTCue', () => {
delete (globalThis as { VTTCue?: unknown }).VTTCue;
expect(session.addFromFile(SRT_FILE)).toBe(false);
expect(session.hasTracks()).toBe(false);
});
});
@@ -0,0 +1,267 @@
import type { PlayerTrack } from '../player-controls/player-controls.model';
import { clampSubtitleDelay } from '../player-controls/subtitle-style';
import {
type ExternalSubtitleFile,
type ParsedSubtitleCue,
WEB_SUBTITLE_FILE_EXTENSIONS,
decodeExternalSubtitleBytes,
detectExternalSubtitleFormat,
parseExternalSubtitleCues,
} from './external-subtitle-cues.util';
/**
* External track IDs live far above every engine ID space (hls.js/Shaka list
* indices, native enumeration counters), so merged listings cannot collide.
*/
export const EXTERNAL_SUBTITLE_TRACK_ID_BASE = 100000;
interface ExternalSubtitleEntry {
id: number;
label: string;
cues: ParsedSubtitleCue[];
track: TextTrack | null;
/** Same order as `cues`; kept so delay changes can re-time exactly. */
trackCues: VTTCue[];
}
export interface WebVideoExternalSubtitlesConfig {
getVideo: () => HTMLVideoElement | null;
/** Turns the engine-owned subtitle selection off when an external track is chosen. */
deselectEngineSubtitles: () => void;
refresh: () => void;
}
/**
* Owns user-loaded subtitle files for a web video engine. Each file becomes a
* native `TextTrack` on the video element, so the browser renders the cues
* (and `::cue` styling applies) regardless of which source engine is active.
*
* The subtitle delay applies to these tracks only: their cues are fully owned
* here, so re-timing is exact. Engine/stream tracks arrive incrementally and
* are left untouched.
*/
export class WebVideoExternalSubtitles {
private entries: ExternalSubtitleEntry[] = [];
private delaySeconds = 0;
private nextId = EXTERNAL_SUBTITLE_TRACK_ID_BASE;
/**
* Every TextTrack this session ever created. `addTextTrack` tracks cannot
* be removed from the element, so ownership must outlive `clear()` — a
* dropped-per-source set would let stale (or attach-failed) tracks
* reappear in the native enumeration as ghost engine tracks.
*/
private readonly createdTracks = new Set<TextTrack>();
constructor(private readonly config: WebVideoExternalSubtitlesConfig) {}
hasTracks(): boolean {
return this.entries.length > 0;
}
/** True while an external track is the one actually rendering. */
hasSelectedTrack(): boolean {
return this.entries.some((entry) => entry.track?.mode === 'showing');
}
ownsTrack(track: TextTrack): boolean {
return this.createdTracks.has(track);
}
ownsTrackId(id: number): boolean {
return this.entries.some((entry) => entry.id === id);
}
/** Parses and attaches the file; returns false when no cue was usable. */
addFromFile(file: ExternalSubtitleFile): boolean {
const cues = parseExternalSubtitleCues(file);
if (cues.length === 0) {
return false;
}
const entry: ExternalSubtitleEntry = {
id: this.nextId,
label: file.name,
cues,
track: null,
trackCues: [],
};
if (!this.attachEntry(entry)) {
return false;
}
this.nextId += 1;
this.entries.push(entry);
this.select(entry.id);
return true;
}
getTracks(): PlayerTrack[] {
return this.entries.map((entry) => ({
id: entry.id,
label: entry.label,
selected: entry.track?.mode === 'showing',
}));
}
select(id: number): void {
if (!this.ownsTrackId(id)) {
return;
}
// Deselect the engine FIRST: hls.js reacts to `subtitleTrack = -1` by
// disabling every subtitle-kind TextTrack on the element, which would
// immediately undo a mode we had already set.
this.config.deselectEngineSubtitles();
for (const entry of this.entries) {
if (entry.track) {
entry.track.mode = entry.id === id ? 'showing' : 'hidden';
}
}
this.config.refresh();
}
deselectAll(): void {
for (const entry of this.entries) {
if (entry.track) {
entry.track.mode = 'hidden';
}
}
}
getDelay(): number {
return this.delaySeconds;
}
setDelay(seconds: number): void {
this.delaySeconds = clampSubtitleDelay(seconds);
for (const entry of this.entries) {
this.applyDelayToEntry(entry);
}
this.config.refresh();
}
/** Per-source teardown: external files correct one specific stream. */
clear(): void {
for (const entry of this.entries) {
this.detachEntry(entry);
}
this.entries = [];
this.delaySeconds = 0;
}
private attachEntry(entry: ExternalSubtitleEntry): boolean {
const video = this.config.getVideo();
const CueCtor = (
globalThis as { VTTCue?: new (
start: number,
end: number,
text: string
) => VTTCue }
).VTTCue;
if (
!video ||
typeof video.addTextTrack !== 'function' ||
typeof CueCtor !== 'function'
) {
return false;
}
try {
const track = video.addTextTrack('subtitles', entry.label);
this.createdTracks.add(track);
entry.track = track;
// Push incrementally so the catch below can remove exactly the
// cues that made it onto the track before a mid-loop failure.
for (const cue of entry.cues) {
const shifted = this.shiftCueTimes(cue);
const vttCue = new CueCtor(
shifted.startSeconds,
shifted.endSeconds,
cue.text
);
track.addCue(vttCue);
entry.trackCues.push(vttCue);
}
return true;
} catch {
// A mid-loop failure leaves an unremovable track on the element:
// silence it so the half-populated cue set can never render. It
// stays in `createdTracks`, so the enumeration keeps excluding it.
this.detachEntry(entry);
return false;
}
}
private detachEntry(entry: ExternalSubtitleEntry): void {
const track = entry.track;
entry.track = null;
if (!track) {
return;
}
try {
for (const cue of entry.trackCues) {
track.removeCue(cue);
}
} catch {
// Removing cues is best-effort; disabling the track hides them.
}
entry.trackCues = [];
track.mode = 'disabled';
}
private applyDelayToEntry(entry: ExternalSubtitleEntry): void {
for (let index = 0; index < entry.trackCues.length; index += 1) {
const shifted = this.shiftCueTimes(entry.cues[index]);
entry.trackCues[index].startTime = shifted.startSeconds;
entry.trackCues[index].endTime = shifted.endSeconds;
}
}
private shiftCueTimes(cue: ParsedSubtitleCue): {
startSeconds: number;
endSeconds: number;
} {
// No clamping: negative cue times are valid VTTCue values that are
// simply never active. Clamping early cues to [0, ~0] would stack
// every pre-roll cue simultaneously at t=0 under a negative delay.
return {
startSeconds: cue.startSeconds + this.delaySeconds,
endSeconds: cue.endSeconds + this.delaySeconds,
};
}
}
/**
* Opens a one-shot subtitle file picker. Runs entirely in the renderer (works
* in the PWA and Electron alike) and hands back the file's name and content —
* no filesystem path ever enters the app, so nothing sensitive can be logged.
*/
export function pickExternalSubtitleFile(
doc: Document,
onPicked: (file: ExternalSubtitleFile) => void
): void {
const input = doc.createElement('input');
input.type = 'file';
input.accept = WEB_SUBTITLE_FILE_EXTENSIONS.join(',');
input.addEventListener('change', () => {
const file = input.files?.[0];
if (!file) {
return;
}
const format = detectExternalSubtitleFormat(file.name);
if (!format) {
return;
}
// Raw bytes, not file.text(): legacy encodings (CP1251/1252, UTF-16)
// are still common for downloaded subtitles and need detection.
void file.arrayBuffer().then(
(buffer) =>
onPicked({
name: file.name,
format,
content: decodeExternalSubtitleBytes(buffer),
}),
() => undefined
);
});
input.click();
}
@@ -19,6 +19,12 @@ export interface WebVideoNativeTextTracksConfig {
* menu keeps working. Shared controls omit it.
*/
playbackStarted?: () => boolean;
/**
* Tracks another owner manages (user-loaded external subtitle files) are
* excluded from this enumeration so they are neither double-listed nor
* touched by the caption-preference suppression.
*/
excludeTrack?: (track: TextTrack) => boolean;
}
export class WebVideoNativeTextTracks {
@@ -115,7 +121,8 @@ export class WebVideoNativeTextTracks {
const track = tracks[index];
if (
!track ||
(track.kind !== 'captions' && track.kind !== 'subtitles')
(track.kind !== 'captions' && track.kind !== 'subtitles') ||
this.config.excludeTrack?.(track) === true
) {
continue;
}
@@ -1,5 +1,7 @@
import type { PlayerTrack } from '../player-controls/player-controls.model';
import type { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter';
import { pickExternalSubtitleFile } from './web-video-external-subtitles';
import { WebVideoSubtitleStyle } from './web-video-subtitle-style';
import {
type WebVideoControlsSource,
WebVideoSourceTracks,
@@ -17,6 +19,7 @@ export interface WebVideoSourceControlsBridgeConfig {
export class WebVideoSourceControlsBridge {
private readonly config: WebVideoSourceControlsBridgeConfig;
private readonly tracks: WebVideoSourceTracks;
private readonly subtitleStyle = new WebVideoSubtitleStyle();
private attached = false;
private destroyed = false;
@@ -34,6 +37,7 @@ export class WebVideoSourceControlsBridge {
return;
}
this.subtitleStyle.attach(this.config.video);
this.config.adapter.attach(this.config.video, {
isLive: this.config.isLive,
getDuration: () => this.readDuration(),
@@ -41,6 +45,13 @@ export class WebVideoSourceControlsBridge {
setAudioTrack: (id) => this.setAudioTrack(id),
getSubtitleTracks: () => this.getSubtitleTracks(),
setSubtitleTrack: (id) => this.setSubtitleTrack(id),
addExternalSubtitleFile: () => this.pickExternalSubtitle(),
getSubtitleDelay: () => this.tracks.getExternalSubtitleDelay(),
setSubtitleDelay: (seconds) =>
this.tracks.setExternalSubtitleDelay(seconds),
canAdjustSubtitleDelay: () => this.tracks.canAdjustSubtitleDelay(),
getSubtitleStyle: () => this.subtitleStyle.current(),
setSubtitleStyle: (style) => this.subtitleStyle.set(style),
getQualityLevels: () => this.tracks.getQualityLevels(),
setQualityLevel: (id) => this.tracks.setQualityLevel(id),
isAutoQualityEnabled: () => this.tracks.isAutoQualityEnabled(),
@@ -82,6 +93,7 @@ export class WebVideoSourceControlsBridge {
this.clearSource();
this.tracks.destroy();
this.subtitleStyle.destroy();
if (this.attached) {
this.config.adapter.detach();
this.attached = false;
@@ -89,6 +101,23 @@ export class WebVideoSourceControlsBridge {
this.destroyed = true;
}
private pickExternalSubtitle(): void {
// The picker is modal-slow: the stream can change (Up Next, zapping,
// failover) before the user confirms. A pick made for one source must
// never attach to its successor.
const generation = this.tracks.getSourceGeneration();
pickExternalSubtitleFile(this.config.video.ownerDocument, (file) => {
if (
this.destroyed ||
this.tracks.getSourceGeneration() !== generation
) {
return;
}
this.tracks.addExternalSubtitleFile(file);
this.config.adapter.refresh();
});
}
readDuration(): number {
if (this.tracks.sourceKind !== 'mpegts' || this.config.isLive()) {
return NaN;
@@ -0,0 +1,98 @@
import { EXTERNAL_SUBTITLE_TRACK_ID_BASE } from './web-video-external-subtitles';
import { WebVideoSourceTracks } from './web-video-source-tracks';
class FakeVTTCue {
constructor(
public startTime: number,
public endTime: number,
public text: string
) {}
}
class FakeTextTrack {
kind = 'subtitles';
mode: TextTrackMode = 'hidden';
readonly added: FakeVTTCue[] = [];
constructor(public label: string) {}
addCue(cue: FakeVTTCue): void {
this.added.push(cue);
}
removeCue(cue: FakeVTTCue): void {
const index = this.added.indexOf(cue);
if (index >= 0) {
this.added.splice(index, 1);
}
}
}
const SRT_FILE = {
name: 'movie.srt',
format: 'srt' as const,
content: ['1', '00:00:01,000 --> 00:00:03,000', 'Hello', ''].join('\n'),
};
function createFakeVideo() {
return {
textTracks: {
length: 0,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
},
addTextTrack: jest.fn(
(_kind: string, label: string) => new FakeTextTrack(label)
),
} as unknown as HTMLVideoElement;
}
describe('WebVideoSourceTracks external subtitle integration', () => {
let tracks: WebVideoSourceTracks;
beforeEach(() => {
(globalThis as { VTTCue?: unknown }).VTTCue = FakeVTTCue;
tracks = new WebVideoSourceTracks({
video: createFakeVideo(),
showCaptions: () => false,
});
tracks.setSource({ kind: 'native' });
});
afterEach(() => {
tracks.destroy();
delete (globalThis as { VTTCue?: unknown }).VTTCue;
});
it('advances the source generation on every source change', () => {
const initial = tracks.getSourceGeneration();
tracks.setSource({ kind: 'mpegts' });
expect(tracks.getSourceGeneration()).toBe(initial + 1);
tracks.clearSource();
expect(tracks.getSourceGeneration()).toBe(initial + 2);
});
it('offers delay adjustment only while an external track is selected', () => {
expect(tracks.canAdjustSubtitleDelay()).toBe(false);
expect(tracks.addExternalSubtitleFile(SRT_FILE)).toBe(true);
expect(tracks.canAdjustSubtitleDelay()).toBe(true);
// Turning subtitles off deselects the external track; the delay UI
// must retire with it — otherwise it is enabled yet visually inert.
tracks.setSubtitleTrack(-1);
expect(tracks.canAdjustSubtitleDelay()).toBe(false);
tracks.setSubtitleTrack(EXTERNAL_SUBTITLE_TRACK_ID_BASE);
expect(tracks.canAdjustSubtitleDelay()).toBe(true);
});
it('drops loaded external files with the source they corrected', () => {
tracks.addExternalSubtitleFile(SRT_FILE);
expect(tracks.getSubtitleTracks()).toHaveLength(1);
tracks.setSource({ kind: 'native' });
expect(tracks.getSubtitleTracks()).toHaveLength(0);
expect(tracks.canAdjustSubtitleDelay()).toBe(false);
});
});
@@ -1,6 +1,8 @@
import type Hls from 'hls.js';
import type { PlayerTrack } from '../player-controls/player-controls.model';
import type { ShakaVideoSession } from '../shaka-engine/shaka-video-session';
import type { ExternalSubtitleFile } from './external-subtitle-cues.util';
import { WebVideoExternalSubtitles } from './web-video-external-subtitles';
import { WebVideoHlsControls } from './web-video-hls-controls';
import { WebVideoNativeTextTracks } from './web-video-native-text-tracks';
import { WebVideoShakaControls } from './web-video-shaka-controls';
@@ -41,7 +43,9 @@ export class WebVideoSourceTracks {
private readonly hlsControls: WebVideoHlsControls;
private readonly shakaControls: WebVideoShakaControls;
private readonly nativeTextTracks: WebVideoNativeTextTracks;
private readonly externalSubtitles: WebVideoExternalSubtitles;
private source: WebVideoControlsSource | null = null;
private sourceGeneration = 0;
private playbackStarted = false;
private playingListener: (() => void) | null = null;
private destroyed = false;
@@ -62,11 +66,17 @@ export class WebVideoSourceTracks {
refresh,
playbackStarted,
});
this.externalSubtitles = new WebVideoExternalSubtitles({
getVideo: () => this.config.video,
deselectEngineSubtitles: () => this.applyEngineSubtitleTrack(-1),
refresh,
});
this.nativeTextTracks = new WebVideoNativeTextTracks({
video: config.video,
showCaptions: config.showCaptions,
refresh,
playbackStarted,
excludeTrack: (track) => this.externalSubtitles.ownsTrack(track),
});
if (config.vendorCaptionControls) {
const listener = () => {
@@ -81,11 +91,21 @@ export class WebVideoSourceTracks {
return this.source?.kind ?? null;
}
/**
* Changes whenever the bound source changes. Asynchronous work started
* against one source (the external subtitle file picker) captures this and
* bails when it no longer matches, so a pick cannot land on a later stream.
*/
getSourceGeneration(): number {
return this.sourceGeneration;
}
setSource(source: WebVideoControlsSource): void {
if (this.destroyed) {
return;
}
this.sourceGeneration += 1;
this.clearActiveSource();
this.source = source;
// A new source starts unsettled so its own defaults are seeded again.
@@ -118,6 +138,7 @@ export class WebVideoSourceTracks {
return;
}
this.sourceGeneration += 1;
this.clearActiveSource();
this.source = null;
}
@@ -186,6 +207,47 @@ export class WebVideoSourceTracks {
}
getSubtitleTracks(): PlayerTrack[] {
// The native enumeration excludes external tracks, so appending them
// here is collision-free for every source kind.
return [
...this.getEngineSubtitleTracks(),
...this.externalSubtitles.getTracks(),
];
}
setSubtitleTrack(id: number): void {
if (this.externalSubtitles.ownsTrackId(id)) {
// select() also turns the engine-owned selection off.
this.externalSubtitles.select(id);
return;
}
this.externalSubtitles.deselectAll();
this.applyEngineSubtitleTrack(id);
}
/** Loads a user-picked subtitle file and selects it. */
addExternalSubtitleFile(file: ExternalSubtitleFile): boolean {
return this.externalSubtitles.addFromFile(file);
}
getExternalSubtitleDelay(): number {
return this.externalSubtitles.getDelay();
}
setExternalSubtitleDelay(seconds: number): void {
this.externalSubtitles.setDelay(seconds);
}
/**
* Delay re-times owned external cues only, so the UI is offered exactly
* while an external track is the one rendering — with an engine track
* selected the row would be enabled yet visually inert.
*/
canAdjustSubtitleDelay(): boolean {
return this.externalSubtitles.hasSelectedTrack();
}
private getEngineSubtitleTracks(): PlayerTrack[] {
if (this.source?.kind === 'hls') {
return this.hlsControls.getSubtitleTracks();
}
@@ -195,7 +257,7 @@ export class WebVideoSourceTracks {
return this.source ? this.nativeTextTracks.getSubtitleTracks() : [];
}
setSubtitleTrack(id: number): void {
private applyEngineSubtitleTrack(id: number): void {
if (this.source?.kind === 'hls') {
this.hlsControls.setSubtitleTrack(id);
} else if (this.source?.kind === 'shaka') {
@@ -206,6 +268,8 @@ export class WebVideoSourceTracks {
}
private clearActiveSource(): void {
// External files correct one specific stream; drop them with it.
this.externalSubtitles.clear();
if (this.source?.kind === 'hls') {
this.hlsControls.clear();
} else if (this.source?.kind === 'shaka') {
@@ -0,0 +1,83 @@
import type { PlayerSubtitleStyle } from '../player-controls/player-controls.model';
import {
isDefaultSubtitleStyle,
normalizeSubtitleStyle,
persistSubtitleStyle,
readStoredSubtitleStyle,
} from '../player-controls/subtitle-style';
let nextStyleScopeId = 0;
/**
* Applies the shared subtitle style (size/color) to a web engine's native cue
* rendering through a scoped `::cue` rule. Native `TextTrack` cues — embedded
* tracks, hls.js-managed tracks, and user-loaded external files — all render
* through the browser's cue display, so one rule covers every source kind.
*
* The style is read from and persisted to the shared localStorage preference,
* so it survives sessions and is shared with the Embedded MPV engine.
*/
export class WebVideoSubtitleStyle {
private style: PlayerSubtitleStyle = readStoredSubtitleStyle();
private styleElement: HTMLStyleElement | null = null;
private video: HTMLVideoElement | null = null;
private readonly scopeClass = `iptv-subtitle-style-${nextStyleScopeId++}`;
attach(video: HTMLVideoElement): void {
if (this.video === video) {
return;
}
this.video?.classList.remove(this.scopeClass);
this.video = video;
video.classList.add(this.scopeClass);
this.applyCss();
}
current(): PlayerSubtitleStyle {
return this.style;
}
set(style: PlayerSubtitleStyle): void {
this.style = normalizeSubtitleStyle(style);
persistSubtitleStyle(this.style);
this.applyCss();
}
destroy(): void {
this.video?.classList.remove(this.scopeClass);
this.video = null;
this.styleElement?.remove();
this.styleElement = null;
}
private applyCss(): void {
const video = this.video;
if (!video) {
return;
}
if (isDefaultSubtitleStyle(this.style)) {
this.styleElement?.remove();
this.styleElement = null;
return;
}
const declarations: string[] = [];
if (this.style.sizePercent !== 100) {
// Percentage resolves against the browser's video-size-derived
// default cue font size, so it scales with the player box.
declarations.push(`font-size: ${this.style.sizePercent}%`);
}
if (this.style.color) {
declarations.push(`color: ${this.style.color}`);
}
const doc = video.ownerDocument;
if (!this.styleElement || !this.styleElement.isConnected) {
this.styleElement?.remove();
this.styleElement = doc.createElement('style');
doc.head.appendChild(this.styleElement);
}
this.styleElement.textContent = `.${this.scopeClass}::cue { ${declarations.join('; ')}; }`;
}
}