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

Extends the engine-neutral PlayerController contract with capability-gated
external subtitle loading, subtitle delay adjustment, and subtitle styling
(size/color), rendered in the shared subtitle popover:

- Contract: new externalSubtitles/subtitleDelay/subtitleStyle capabilities,
  subtitleDelaySeconds + subtitleStyle state, addExternalSubtitleFile/
  setSubtitleDelay/setSubtitleStyle commands. subtitle-style.ts owns presets,
  clamping, and localStorage persistence of size/color (shared across
  engines, volume-key precedent); delay and the loaded file stay per-session.
- HTML5 + ArtPlayer (shared-controls mode): renderer-side .srt/.vtt picker
  (no filesystem path enters the app), SRT/VTT cue parser, external files
  rendered as native TextTracks merged into the track list with exclusive
  selection, exact cue re-timing for delay (gated on a loaded file), and
  scoped ::cue styling.
- Embedded MPV frame-copy: new sub-add/sub-delay/sub-scale/sub-color helper
  protocol commands, main-process subtitle file dialog (.srt/.ass/.ssa/.vtt/
  .sub; mpv renders ASS natively), new IPC channels + preload + bridge API,
  renderer-side delay/style ownership with per-session reset and persisted
  style re-application.
- Video.js shared mode, vendor-chrome paths, native-view, and the Linux
  out-of-process path advertise no capability and render no UI.

Adds focused specs for the parser, external-subtitle session, style helper,
web adapter, popover interactions, embedded settings, command-runner IPC, and
frame-copy protocol lines; updates all 19 i18n files, the player-controls
contract doc, CLAUDE.md, and a release note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-22 23:10:42 +02:00
1 parent cb37f628ed
commit 404deb92b0
70 files changed
+2773 -50

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.
+1 -1
View File
@@ -1101,7 +1101,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. 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 command wiring (`html-video-legacy-shortcuts.ts`, `vjs-legacy-shortcuts.ts`, `art-player-legacy-shortcuts.ts`); seek is gated on authoritative `isLive` plus a finite positive duration, `interactionEnabled` (visible playback diagnostic) disables the keys, and the legacy ArtPlayer chrome passes `hotkey: false` because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would double-handle every key (its lost Escape-exits-`fullscreenWeb` behavior is restored by the wiring). `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adaptLine 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, native `TextTrack` rendering, `::cue` styling, delay only for the loaded file's owned cues); 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"). 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 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, arroLine truncated
- Shared web picture-in-picture stays inside that default-off rollout.
`PlayerController` exposes capability `pictureInPicture`, state
`pictureInPictureActive`/`canPictureInPicture`, and command
@@ -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,
@@ -586,6 +587,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,
@@ -74,6 +75,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;
@@ -240,6 +244,9 @@ export class EmbeddedMpvNativeService {
aspectOverride: true,
screenshot: false,
recording: true,
externalSubtitles: true,
subtitleDelay: true,
subtitleStyle: true,
};
}
const addon = this.addon;
@@ -251,6 +258,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',
};
}
@@ -561,6 +571,84 @@ 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.'
);
}
const clamped = Number.isFinite(seconds)
? Math.max(-60, Math.min(60, seconds))
: 0;
addon.setSubtitleDelay(sessionId, clamped);
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.'
);
}
const sizePercent =
typeof style?.sizePercent === 'number' &&
Number.isFinite(style.sizePercent)
? Math.max(25, Math.min(400, Math.round(style.sizePercent)))
: 100;
const color =
typeof style?.color === 'string' &&
/^#[0-9a-f]{6}$/i.test(style.color)
? style.color
: null;
addon.setSubtitleStyle(sessionId, { sizePercent, color });
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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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
@@ -553,7 +553,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": {
+66 -1
View File
@@ -172,7 +172,8 @@ interface PlayerController {
### Capabilities
`PlayerControlsCapabilities` contains booleans for `seek`, `volume`,
`audioTracks`, `subtitles`, `playbackSpeed`, `aspectRatio`, `recording`,
`audioTracks`, `subtitles`, `externalSubtitles`, `subtitleDelay`,
`subtitleStyle`, `playbackSpeed`, `aspectRatio`, `recording`,
`pictureInPicture`, `fullscreen`, and `seriesNavigation`.
The default is all-false. An adapter enables only features that its engine and
@@ -208,6 +209,7 @@ owner.
- `seekTo` / `seekBy`
- `setVolume`
- `setAudioTrack` / `setSubtitleTrack`
- `addExternalSubtitleFile` / `setSubtitleDelay` / `setSubtitleStyle`
- `setPlaybackSpeed`
- `setAspectRatio`
- `toggleRecording`
@@ -587,6 +589,69 @@ 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.
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).
`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, and
`WebVideoSourceTracks` merges them into the subtitle listing with IDs from
100000 up, routing selection so exactly one owner (engine or external) is
active. The delay capability is runtime-gated on a loaded file: only owned
cues can be re-timed exactly, while engine/stream cues arrive incrementally
and are left untouched. 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.
- **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
@@ -2,6 +2,7 @@ import {
EmbeddedMpvBounds,
EmbeddedMpvRecordingStartOptions,
EmbeddedMpvSession,
EmbeddedMpvSubtitleStyle,
EmbeddedMpvSupport,
} from './embedded-mpv-session.interface';
import { ContentMetadataPatch } from './content-metadata.interface';
@@ -1124,6 +1125,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
@@ -20,6 +20,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;
}
/**
* Engine-neutral subtitle presentation preferences forwarded to mpv.
* `sizePercent` maps to `sub-scale` (100 = 1.0); `color` maps to `sub-color`,
* null restores mpv's default.
*/
export interface EmbeddedMpvSubtitleStyle {
sizePercent: number;
color: string | null;
}
export type EmbeddedMpvEngine = 'native' | 'frame-copy';
@@ -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';
@@ -326,4 +326,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,5 +1,8 @@
import { Signal, WritableSignal } from '@angular/core';
import { EmbeddedMpvSession } from '@iptvnator/shared/interfaces';
import {
EmbeddedMpvSession,
EmbeddedMpvSubtitleStyle,
} from '@iptvnator/shared/interfaces';
type ElectronBridge = Window['electron'];
@@ -85,6 +88,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,
playbackSpeed: true,
aspectRatio: true,
recording: true,
@@ -220,6 +226,9 @@ describe('EmbeddedMpvControlsAdapter', () => {
volume: true,
audioTracks: true,
subtitles: false,
externalSubtitles: false,
subtitleDelay: false,
subtitleStyle: false,
playbackSpeed: false,
aspectRatio: false,
recording: false,
@@ -38,6 +38,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>;
@@ -87,6 +88,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();
@@ -111,6 +115,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,
@@ -176,6 +184,8 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
})
),
subtitlesEnabled: (session?.selectedSubtitleTrackId ?? -1) >= 0,
subtitleDelaySeconds: this.subtitleSettings.delaySeconds(),
subtitleStyle: this.subtitleSettings.style(),
playbackSpeed: session?.playbackSpeed ?? 1,
speedPresets: DEFAULT_SPEED_PRESETS,
aspectRatio: session?.aspectOverride ?? 'no',
@@ -207,6 +217,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),
setPlaybackSpeed: (speed) => void this.controller.setSpeed(speed),
setAspectRatio: (value) => void this.controller.setAspect(value),
toggleRecording: () => void this.toggleRecording(),
@@ -235,6 +249,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,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
@@ -300,6 +301,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(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: 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());
}
speed(value: number): void {
this.apply('speed', (c) => c.setPlaybackSpeed(value));
}
@@ -26,10 +26,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),
speed: showControls && capabilities.playbackSpeed,
aspect: showControls && capabilities.aspectRatio,
};
@@ -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 canRecord = computed(
() =>
@@ -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,
playbackSpeed: false,
aspectRatio: false,
recording: false,
@@ -47,6 +51,8 @@ export function createEmptyControlsState(): PlayerControlsState {
audioTracks: [],
subtitleTracks: [],
subtitlesEnabled: false,
subtitleDelaySeconds: 0,
subtitleStyle: { ...DEFAULT_SUBTITLE_STYLE },
playbackSpeed: 1,
speedPresets: DEFAULT_SPEED_PRESETS,
aspectRatio: 'no',
@@ -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(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
@@ -360,26 +360,33 @@
}}
</div>
<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,7 +411,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(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: 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(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: 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(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
@@ -501,6 +501,95 @@
height: 18px;
}
.player-controls__load-subtitle mat-icon {
color: rgba(255, 255, 255, 0.85);
}
.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(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: 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(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: 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(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: 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,
@@ -228,6 +239,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()) {
@@ -304,6 +319,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;
@@ -14,6 +14,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;
playbackSpeed: boolean;
aspectRatio: boolean;
recording: boolean;
@@ -22,6 +28,16 @@ export interface PlayerControlsCapabilities {
seriesNavigation: boolean;
}
/**
* Engine-neutral subtitle presentation preferences. `sizePercent` is relative
* to the engine's default rendering size (100 = default); `color` is a CSS hex
* color or null for the engine default.
*/
export interface PlayerSubtitleStyle {
sizePercent: number;
color: string | null;
}
export interface PlayerTrack {
id: number;
label: string; // adapter pre-computes the display label
@@ -69,6 +85,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;
playbackSpeed: number;
speedPresets: ReadonlyArray<PlayerPreset<number>>;
aspectRatio: string;
@@ -88,6 +108,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;
setPlaybackSpeed(speed: number): void;
setAspectRatio(value: string): void;
toggleRecording(): void;
@@ -0,0 +1,99 @@
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');
});
});
});
@@ -0,0 +1,121 @@
import type { PlayerPreset, PlayerSubtitleStyle } from './player-controls.model';
/**
* Shared subtitle presentation preferences. 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 const SUBTITLE_STYLE_STORAGE_KEY = 'subtitleStyle';
export const DEFAULT_SUBTITLE_STYLE: PlayerSubtitleStyle = {
sizePercent: 100,
color: null,
};
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 const SUBTITLE_DELAY_LIMIT_SECONDS = 60;
const MIN_SIZE_PERCENT = 25;
const MAX_SIZE_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(
MIN_SIZE_PERCENT,
Math.min(MAX_SIZE_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
);
}
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) || seconds === 0) {
return '0 s';
}
const rounded = Math.round(seconds * 10) / 10;
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>;
}
interface WebVideoControlsContext {
@@ -100,6 +110,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',
aspectRatio: false,
recording: false,
pictureInPicture: pictureInPicture.supported,
@@ -138,6 +154,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,
playbackSpeed: video?.playbackRate ?? 1,
speedPresets: DEFAULT_SPEED_PRESETS,
aspectRatio: 'no',
@@ -172,6 +191,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()
),
setPlaybackSpeed: (speed) => applyVideoSpeed(this.video, speed),
setAspectRatio: () => undefined,
toggleRecording: () => undefined,
@@ -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,119 @@
import {
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('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,116 @@
/**
* 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;
}
// 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,172 @@
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 clamps a cue start at zero without inverting it.
session.setDelay(-2);
expect(track.added[0].startTime).toBe(0);
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);
});
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,236 @@
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,
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;
constructor(private readonly config: WebVideoExternalSubtitlesConfig) {}
hasTracks(): boolean {
return this.entries.length > 0;
}
ownsTrack(track: TextTrack): boolean {
return this.entries.some((entry) => entry.track === 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;
}
for (const entry of this.entries) {
if (entry.track) {
entry.track.mode = entry.id === id ? 'showing' : 'hidden';
}
}
this.config.deselectEngineSubtitles();
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);
entry.track = track;
entry.trackCues = entry.cues.map((cue) => {
const shifted = this.shiftCueTimes(cue);
const vttCue = new CueCtor(
shifted.startSeconds,
shifted.endSeconds,
cue.text
);
track.addCue(vttCue);
return vttCue;
});
return true;
} catch {
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;
} {
const startSeconds = Math.max(0, cue.startSeconds + this.delaySeconds);
const endSeconds = Math.max(
startSeconds + 0.001,
cue.endSeconds + this.delaySeconds
);
return { startSeconds, endSeconds };
}
}
/**
* 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;
}
void file.text().then(
(content) => onPicked({ name: file.name, format, content }),
() => 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),
});
this.attached = true;
}
@@ -79,6 +90,7 @@ export class WebVideoSourceControlsBridge {
this.clearSource();
this.tracks.destroy();
this.subtitleStyle.destroy();
if (this.attached) {
this.config.adapter.detach();
this.attached = false;
@@ -86,6 +98,16 @@ export class WebVideoSourceControlsBridge {
this.destroyed = true;
}
private pickExternalSubtitle(): void {
pickExternalSubtitleFile(this.config.video.ownerDocument, (file) => {
if (this.destroyed) {
return;
}
this.tracks.addExternalSubtitleFile(file);
this.config.adapter.refresh();
});
}
readDuration(): number {
if (this.tracks.sourceKind !== 'mpegts' || this.config.isLive()) {
return NaN;
@@ -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,6 +43,7 @@ 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 playbackStarted = false;
private playingListener: (() => void) | null = null;
@@ -62,11 +65,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 = () => {
@@ -157,6 +166,43 @@ 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 applies to owned external cues only, so it needs a loaded file. */
canAdjustSubtitleDelay(): boolean {
return this.externalSubtitles.hasTracks();
}
private getEngineSubtitleTracks(): PlayerTrack[] {
if (this.source?.kind === 'hls') {
return this.hlsControls.getSubtitleTracks();
}
@@ -166,7 +212,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') {
@@ -177,6 +223,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('; ')}; }`;
}
}