mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
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:
1 parent
cb37f628ed
commit
404deb92b0
70 files changed
+2773
-50
No files matched your search
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+3
@@ -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(),
|
||||
|
||||
+3
@@ -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(),
|
||||
|
||||
+248
@@ -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`;
|
||||
}
|
||||
+107
@@ -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('; ')}; }`;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user