mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 12:11:28 -07:00
feat(subtitle-sidebar): add sidebar runtime and modal plumbing
This commit is contained in:
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
|
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
|
||||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
|
import type { SubtitleSidebarSnapshot } from '../../types';
|
||||||
|
|
||||||
interface FakeIpcRegistrar {
|
interface FakeIpcRegistrar {
|
||||||
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
|
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
|
||||||
@@ -77,6 +78,30 @@ function createControllerConfigFixture() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
|
||||||
|
return {
|
||||||
|
cues: [],
|
||||||
|
currentSubtitle: { text: '', startTime: null, endTime: null },
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
layout: 'overlay',
|
||||||
|
toggleKey: 'Backslash',
|
||||||
|
pauseVideoOnHover: false,
|
||||||
|
autoScroll: true,
|
||||||
|
maxWidth: 420,
|
||||||
|
opacity: 0.92,
|
||||||
|
backgroundColor: 'rgba(54, 58, 79, 0.88)',
|
||||||
|
textColor: '#cad3f5',
|
||||||
|
fontFamily: '"M PLUS 1", "Noto Sans CJK JP", sans-serif',
|
||||||
|
fontSize: 16,
|
||||||
|
timestampColor: '#a5adcb',
|
||||||
|
activeLineColor: '#f5bde6',
|
||||||
|
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||||
|
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServiceDeps {
|
function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServiceDeps {
|
||||||
return {
|
return {
|
||||||
onOverlayModalClosed: () => {},
|
onOverlayModalClosed: () => {},
|
||||||
@@ -88,6 +113,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
|
|||||||
tokenizeCurrentSubtitle: async () => null,
|
tokenizeCurrentSubtitle: async () => null,
|
||||||
getCurrentSubtitleRaw: () => '',
|
getCurrentSubtitleRaw: () => '',
|
||||||
getCurrentSubtitleAss: () => '',
|
getCurrentSubtitleAss: () => '',
|
||||||
|
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
|
||||||
getPlaybackPaused: () => false,
|
getPlaybackPaused: () => false,
|
||||||
getSubtitlePosition: () => null,
|
getSubtitlePosition: () => null,
|
||||||
getSubtitleStyle: () => null,
|
getSubtitleStyle: () => null,
|
||||||
@@ -173,6 +199,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
tokenizeCurrentSubtitle: async () => null,
|
tokenizeCurrentSubtitle: async () => null,
|
||||||
getCurrentSubtitleRaw: () => '',
|
getCurrentSubtitleRaw: () => '',
|
||||||
getCurrentSubtitleAss: () => '',
|
getCurrentSubtitleAss: () => '',
|
||||||
|
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
|
||||||
getPlaybackPaused: () => true,
|
getPlaybackPaused: () => true,
|
||||||
getSubtitlePosition: () => null,
|
getSubtitlePosition: () => null,
|
||||||
getSubtitleStyle: () => null,
|
getSubtitleStyle: () => null,
|
||||||
@@ -269,6 +296,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
cycles.push({ id, direction });
|
cycles.push({ id, direction });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
|
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
|
||||||
reportOverlayContentBounds: () => {},
|
reportOverlayContentBounds: () => {},
|
||||||
getAnilistStatus: () => ({}),
|
getAnilistStatus: () => ({}),
|
||||||
clearAnilistToken: () => {},
|
clearAnilistToken: () => {},
|
||||||
@@ -320,6 +348,24 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('registerIpcHandlers exposes subtitle sidebar snapshot request', async () => {
|
||||||
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
|
const snapshot = createSubtitleSidebarSnapshotFixture();
|
||||||
|
snapshot.cues = [{ startTime: 1, endTime: 2, text: 'line-1' }];
|
||||||
|
snapshot.config.enabled = true;
|
||||||
|
|
||||||
|
registerIpcHandlers(
|
||||||
|
createRegisterIpcDeps({
|
||||||
|
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||||
|
}),
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handler = handlers.handle.get(IPC_CHANNELS.request.getSubtitleSidebarSnapshot);
|
||||||
|
assert.ok(handler);
|
||||||
|
assert.deepEqual(await handler!({}), snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
|
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
@@ -530,6 +576,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
tokenizeCurrentSubtitle: async () => null,
|
tokenizeCurrentSubtitle: async () => null,
|
||||||
getCurrentSubtitleRaw: () => '',
|
getCurrentSubtitleRaw: () => '',
|
||||||
getCurrentSubtitleAss: () => '',
|
getCurrentSubtitleAss: () => '',
|
||||||
|
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
|
||||||
getPlaybackPaused: () => false,
|
getPlaybackPaused: () => false,
|
||||||
getSubtitlePosition: () => null,
|
getSubtitlePosition: () => null,
|
||||||
getSubtitleStyle: () => null,
|
getSubtitleStyle: () => null,
|
||||||
@@ -596,6 +643,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
|||||||
tokenizeCurrentSubtitle: async () => null,
|
tokenizeCurrentSubtitle: async () => null,
|
||||||
getCurrentSubtitleRaw: () => '',
|
getCurrentSubtitleRaw: () => '',
|
||||||
getCurrentSubtitleAss: () => '',
|
getCurrentSubtitleAss: () => '',
|
||||||
|
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
|
||||||
getPlaybackPaused: () => false,
|
getPlaybackPaused: () => false,
|
||||||
getSubtitlePosition: () => null,
|
getSubtitlePosition: () => null,
|
||||||
getSubtitleStyle: () => null,
|
getSubtitleStyle: () => null,
|
||||||
@@ -667,6 +715,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
|||||||
tokenizeCurrentSubtitle: async () => null,
|
tokenizeCurrentSubtitle: async () => null,
|
||||||
getCurrentSubtitleRaw: () => '',
|
getCurrentSubtitleRaw: () => '',
|
||||||
getCurrentSubtitleAss: () => '',
|
getCurrentSubtitleAss: () => '',
|
||||||
|
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
|
||||||
getPlaybackPaused: () => false,
|
getPlaybackPaused: () => false,
|
||||||
getSubtitlePosition: () => null,
|
getSubtitlePosition: () => null,
|
||||||
getSubtitleStyle: () => null,
|
getSubtitleStyle: () => null,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
ResolvedControllerConfig,
|
ResolvedControllerConfig,
|
||||||
RuntimeOptionId,
|
RuntimeOptionId,
|
||||||
RuntimeOptionValue,
|
RuntimeOptionValue,
|
||||||
|
SubtitleSidebarSnapshot,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
SubsyncManualRunRequest,
|
SubsyncManualRunRequest,
|
||||||
SubsyncResult,
|
SubsyncResult,
|
||||||
@@ -37,6 +38,7 @@ export interface IpcServiceDeps {
|
|||||||
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
||||||
getCurrentSubtitleRaw: () => string;
|
getCurrentSubtitleRaw: () => string;
|
||||||
getCurrentSubtitleAss: () => string;
|
getCurrentSubtitleAss: () => string;
|
||||||
|
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
|
||||||
getPlaybackPaused: () => boolean | null;
|
getPlaybackPaused: () => boolean | null;
|
||||||
getSubtitlePosition: () => unknown;
|
getSubtitlePosition: () => unknown;
|
||||||
getSubtitleStyle: () => unknown;
|
getSubtitleStyle: () => unknown;
|
||||||
@@ -143,6 +145,7 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
||||||
getCurrentSubtitleRaw: () => string;
|
getCurrentSubtitleRaw: () => string;
|
||||||
getCurrentSubtitleAss: () => string;
|
getCurrentSubtitleAss: () => string;
|
||||||
|
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
|
||||||
getPlaybackPaused: () => boolean | null;
|
getPlaybackPaused: () => boolean | null;
|
||||||
getSubtitlePosition: () => unknown;
|
getSubtitlePosition: () => unknown;
|
||||||
getSubtitleStyle: () => unknown;
|
getSubtitleStyle: () => unknown;
|
||||||
@@ -190,6 +193,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
|
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
|
||||||
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
|
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
|
||||||
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
|
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
|
||||||
|
getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot,
|
||||||
getPlaybackPaused: options.getPlaybackPaused,
|
getPlaybackPaused: options.getPlaybackPaused,
|
||||||
getSubtitlePosition: options.getSubtitlePosition,
|
getSubtitlePosition: options.getSubtitlePosition,
|
||||||
getSubtitleStyle: options.getSubtitleStyle,
|
getSubtitleStyle: options.getSubtitleStyle,
|
||||||
@@ -321,6 +325,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
return deps.getCurrentSubtitleAss();
|
return deps.getCurrentSubtitleAss();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarSnapshot, async () => {
|
||||||
|
if (!deps.getSubtitleSidebarSnapshot) {
|
||||||
|
throw new Error('Subtitle sidebar snapshot is unavailable.');
|
||||||
|
}
|
||||||
|
return await deps.getSubtitleSidebarSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.request.getPlaybackPaused, () => {
|
ipc.handle(IPC_CHANNELS.request.getPlaybackPaused, () => {
|
||||||
return deps.getPlaybackPaused();
|
return deps.getPlaybackPaused();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -183,7 +183,13 @@ export function parseSubtitleCues(content: string, filename: string): SubtitleCu
|
|||||||
cues = parseAssCues(content);
|
cues = parseAssCues(content);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return [];
|
cues = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cues.length === 0) {
|
||||||
|
const assCues = parseAssCues(content);
|
||||||
|
const srtCues = parseSrtCues(content);
|
||||||
|
cues = assCues.length >= srtCues.length ? assCues : srtCues;
|
||||||
}
|
}
|
||||||
|
|
||||||
cues.sort((a, b) => a.startTime - b.startTime);
|
cues.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|||||||
325
src/main.ts
325
src/main.ts
@@ -438,10 +438,11 @@ import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
|
|||||||
import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch';
|
import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch';
|
||||||
import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch';
|
import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch';
|
||||||
import {
|
import {
|
||||||
getActiveExternalSubtitleSource,
|
buildSubtitleSidebarSourceKey,
|
||||||
resolveSubtitleSourcePath,
|
resolveSubtitleSourcePath,
|
||||||
} from './main/runtime/subtitle-prefetch-source';
|
} from './main/runtime/subtitle-prefetch-source';
|
||||||
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
|
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
|
||||||
|
import { codecToExtension } from './subsync/utils';
|
||||||
|
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
||||||
@@ -1142,15 +1143,23 @@ function maybeSignalPluginAutoplayReady(
|
|||||||
|
|
||||||
let appTray: Tray | null = null;
|
let appTray: Tray | null = null;
|
||||||
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
|
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
|
||||||
|
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||||
|
endTime: appState.mpvClient?.currentSubEnd ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
function emitSubtitlePayload(payload: SubtitleData): void {
|
function emitSubtitlePayload(payload: SubtitleData): void {
|
||||||
appState.currentSubtitleData = payload;
|
const timedPayload = withCurrentSubtitleTiming(payload);
|
||||||
broadcastToOverlayWindows('subtitle:set', payload);
|
appState.currentSubtitleData = timedPayload;
|
||||||
subtitleWsService.broadcast(payload, {
|
broadcastToOverlayWindows('subtitle:set', timedPayload);
|
||||||
|
subtitleWsService.broadcast(timedPayload, {
|
||||||
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||||
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
||||||
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
||||||
});
|
});
|
||||||
annotationSubtitleWsService.broadcast(payload, {
|
annotationSubtitleWsService.broadcast(timedPayload, {
|
||||||
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||||
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
|
||||||
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
|
||||||
@@ -1200,6 +1209,10 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
|||||||
isCacheFull: () => subtitleProcessingController.isCacheFull(),
|
isCacheFull: () => subtitleProcessingController.isCacheFull(),
|
||||||
logInfo: (message) => logger.info(message),
|
logInfo: (message) => logger.info(message),
|
||||||
logWarn: (message) => logger.warn(message),
|
logWarn: (message) => logger.warn(message),
|
||||||
|
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
|
||||||
|
appState.activeParsedSubtitleCues = cues ?? [];
|
||||||
|
appState.activeParsedSubtitleSource = sourceKey;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
|
async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
|
||||||
@@ -1209,19 +1222,40 @@ async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [trackListRaw, sidRaw] = await Promise.all([
|
const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] =
|
||||||
|
await Promise.all([
|
||||||
|
client.requestProperty('current-tracks/sub/external-filename').catch(() => null),
|
||||||
|
client.requestProperty('current-tracks/sub').catch(() => null),
|
||||||
client.requestProperty('track-list'),
|
client.requestProperty('track-list'),
|
||||||
client.requestProperty('sid'),
|
client.requestProperty('sid'),
|
||||||
|
client.requestProperty('path'),
|
||||||
]);
|
]);
|
||||||
const externalFilename = getActiveExternalSubtitleSource(trackListRaw, sidRaw);
|
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
|
||||||
if (!externalFilename) {
|
if (!videoPath) {
|
||||||
subtitlePrefetchInitController.cancelPendingInit();
|
subtitlePrefetchInitController.cancelPendingInit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await subtitlePrefetchInitController.initSubtitlePrefetch(
|
|
||||||
externalFilename,
|
const resolvedSource = await resolveActiveSubtitleSidebarSource(
|
||||||
lastObservedTimePos,
|
currentExternalFilenameRaw,
|
||||||
|
currentTrackRaw,
|
||||||
|
trackListRaw,
|
||||||
|
sidRaw,
|
||||||
|
videoPath,
|
||||||
);
|
);
|
||||||
|
if (!resolvedSource) {
|
||||||
|
subtitlePrefetchInitController.cancelPendingInit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await subtitlePrefetchInitController.initSubtitlePrefetch(
|
||||||
|
resolvedSource.path,
|
||||||
|
lastObservedTimePos,
|
||||||
|
resolvedSource.sourceKey,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await resolvedSource.cleanup?.();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Track list query failed; skip subtitle prefetch refresh.
|
// Track list query failed; skip subtitle prefetch refresh.
|
||||||
}
|
}
|
||||||
@@ -2965,6 +2999,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
|||||||
? {
|
? {
|
||||||
text: appState.currentSubText,
|
text: appState.currentSubText,
|
||||||
tokens: null,
|
tokens: null,
|
||||||
|
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||||
|
endTime: appState.mpvClient?.currentSubEnd ?? null,
|
||||||
}
|
}
|
||||||
: null),
|
: null),
|
||||||
() => ({
|
() => ({
|
||||||
@@ -2983,6 +3019,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
|||||||
? {
|
? {
|
||||||
text: appState.currentSubText,
|
text: appState.currentSubText,
|
||||||
tokens: null,
|
tokens: null,
|
||||||
|
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||||
|
endTime: appState.mpvClient?.currentSubEnd ?? null,
|
||||||
}
|
}
|
||||||
: null),
|
: null),
|
||||||
() => ({
|
() => ({
|
||||||
@@ -3257,6 +3295,9 @@ const {
|
|||||||
restoreMpvSubVisibility: () => {
|
restoreMpvSubVisibility: () => {
|
||||||
restoreOverlayMpvSubtitles();
|
restoreOverlayMpvSubtitles();
|
||||||
},
|
},
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => {
|
||||||
|
resetSubtitleSidebarEmbeddedLayoutRuntime();
|
||||||
|
},
|
||||||
getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(),
|
getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(),
|
||||||
resetAnilistMediaTracking: (mediaKey) => {
|
resetAnilistMediaTracking: (mediaKey) => {
|
||||||
resetAnilistMediaTracking(mediaKey);
|
resetAnilistMediaTracking(mediaKey);
|
||||||
@@ -3475,6 +3516,11 @@ function createMpvClientRuntimeService(): MpvIpcClient {
|
|||||||
return createMpvClientRuntimeServiceHandler() as MpvIpcClient;
|
return createMpvClientRuntimeServiceHandler() as MpvIpcClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetSubtitleSidebarEmbeddedLayoutRuntime(): void {
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-margin-ratio-right', 0]);
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'video-pan-x', 0]);
|
||||||
|
}
|
||||||
|
|
||||||
function updateMpvSubtitleRenderMetrics(patch: Partial<MpvSubtitleRenderMetrics>): void {
|
function updateMpvSubtitleRenderMetrics(patch: Partial<MpvSubtitleRenderMetrics>): void {
|
||||||
updateMpvSubtitleRenderMetricsHandler(patch);
|
updateMpvSubtitleRenderMetricsHandler(patch);
|
||||||
}
|
}
|
||||||
@@ -3919,6 +3965,168 @@ async function loadSubtitleSourceText(source: string): Promise<string> {
|
|||||||
return fs.promises.readFile(filePath, 'utf8');
|
return fs.promises.readFile(filePath, 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MpvSubtitleTrackLike = {
|
||||||
|
type?: unknown;
|
||||||
|
id?: unknown;
|
||||||
|
selected?: unknown;
|
||||||
|
external?: unknown;
|
||||||
|
codec?: unknown;
|
||||||
|
'ff-index'?: unknown;
|
||||||
|
'external-filename'?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseTrackId(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value.trim());
|
||||||
|
return Number.isInteger(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveSubtitleTrack(
|
||||||
|
currentTrackRaw: unknown,
|
||||||
|
trackListRaw: unknown,
|
||||||
|
sidRaw: unknown,
|
||||||
|
): MpvSubtitleTrackLike | null {
|
||||||
|
if (currentTrackRaw && typeof currentTrackRaw === 'object') {
|
||||||
|
const track = currentTrackRaw as MpvSubtitleTrackLike;
|
||||||
|
if (track.type === undefined || track.type === 'sub') {
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid = parseTrackId(sidRaw);
|
||||||
|
if (!Array.isArray(trackListRaw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bySid =
|
||||||
|
sid === null
|
||||||
|
? null
|
||||||
|
: ((trackListRaw.find((entry: unknown) => {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const track = entry as MpvSubtitleTrackLike;
|
||||||
|
return track.type === 'sub' && parseTrackId(track.id) === sid;
|
||||||
|
}) as MpvSubtitleTrackLike | undefined) ?? null);
|
||||||
|
if (bySid) {
|
||||||
|
return bySid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(trackListRaw.find((entry: unknown) => {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const track = entry as MpvSubtitleTrackLike;
|
||||||
|
return track.type === 'sub' && track.selected === true;
|
||||||
|
}) as MpvSubtitleTrackLike | undefined) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFfmpegSubtitleExtractionArgs(
|
||||||
|
videoPath: string,
|
||||||
|
ffIndex: number,
|
||||||
|
outputPath: string,
|
||||||
|
): string[] {
|
||||||
|
return [
|
||||||
|
'-hide_banner',
|
||||||
|
'-nostdin',
|
||||||
|
'-y',
|
||||||
|
'-loglevel',
|
||||||
|
'error',
|
||||||
|
'-an',
|
||||||
|
'-vn',
|
||||||
|
'-i',
|
||||||
|
videoPath,
|
||||||
|
'-map',
|
||||||
|
`0:${ffIndex}`,
|
||||||
|
'-f',
|
||||||
|
path.extname(outputPath).slice(1),
|
||||||
|
outputPath,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractInternalSubtitleTrackToTempFile(
|
||||||
|
ffmpegPath: string,
|
||||||
|
videoPath: string,
|
||||||
|
track: MpvSubtitleTrackLike,
|
||||||
|
): Promise<{ path: string; cleanup: () => Promise<void> } | null> {
|
||||||
|
const ffIndex = typeof track['ff-index'] === 'number' ? track['ff-index'] : null;
|
||||||
|
const codec = typeof track.codec === 'string' ? track.codec : null;
|
||||||
|
const extension = codecToExtension(codec ?? undefined);
|
||||||
|
if (ffIndex === null || extension === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-'));
|
||||||
|
const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn(ffmpegPath, buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath));
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: outputPath,
|
||||||
|
cleanup: async () => {
|
||||||
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveActiveSubtitleSidebarSource(
|
||||||
|
currentExternalFilenameRaw: unknown,
|
||||||
|
currentTrackRaw: unknown,
|
||||||
|
trackListRaw: unknown,
|
||||||
|
sidRaw: unknown,
|
||||||
|
videoPath: string,
|
||||||
|
): Promise<{ path: string; sourceKey: string; cleanup?: () => Promise<void> } | null> {
|
||||||
|
const currentExternalFilename =
|
||||||
|
typeof currentExternalFilenameRaw === 'string' ? currentExternalFilenameRaw.trim() : '';
|
||||||
|
if (currentExternalFilename) {
|
||||||
|
return { path: currentExternalFilename, sourceKey: currentExternalFilename };
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = getActiveSubtitleTrack(currentTrackRaw, trackListRaw, sidRaw);
|
||||||
|
if (!track) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalFilename =
|
||||||
|
typeof track['external-filename'] === 'string' ? track['external-filename'].trim() : '';
|
||||||
|
if (externalFilename) {
|
||||||
|
return { path: externalFilename, sourceKey: externalFilename };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ffmpegPath = getResolvedConfig().subsync.ffmpeg_path.trim() || 'ffmpeg';
|
||||||
|
const extracted = await extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track);
|
||||||
|
if (!extracted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...extracted,
|
||||||
|
sourceKey: buildSubtitleSidebarSourceKey(videoPath, track, extracted.path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({
|
const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
loadSubtitleSourceText,
|
loadSubtitleSourceText,
|
||||||
@@ -3976,9 +4184,102 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
quitApp: () => requestAppQuit(),
|
quitApp: () => requestAppQuit(),
|
||||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||||
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
|
tokenizeCurrentSubtitle: async () => withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)),
|
||||||
getCurrentSubtitleRaw: () => appState.currentSubText,
|
getCurrentSubtitleRaw: () => appState.currentSubText,
|
||||||
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
||||||
|
getSubtitleSidebarSnapshot: async () => {
|
||||||
|
const currentSubtitle = {
|
||||||
|
text: appState.currentSubText,
|
||||||
|
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||||
|
endTime: appState.mpvClient?.currentSubEnd ?? null,
|
||||||
|
};
|
||||||
|
const currentTimeSec = appState.mpvClient?.currentTimePos ?? null;
|
||||||
|
const config = getResolvedConfig().subtitleSidebar;
|
||||||
|
const client = appState.mpvClient;
|
||||||
|
if (!client?.connected) {
|
||||||
|
return {
|
||||||
|
cues: appState.activeParsedSubtitleCues,
|
||||||
|
currentTimeSec,
|
||||||
|
currentSubtitle,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
currentExternalFilenameRaw,
|
||||||
|
currentTrackRaw,
|
||||||
|
trackListRaw,
|
||||||
|
sidRaw,
|
||||||
|
videoPathRaw,
|
||||||
|
] = await Promise.all([
|
||||||
|
client.requestProperty('current-tracks/sub/external-filename').catch(() => null),
|
||||||
|
client.requestProperty('current-tracks/sub').catch(() => null),
|
||||||
|
client.requestProperty('track-list'),
|
||||||
|
client.requestProperty('sid'),
|
||||||
|
client.requestProperty('path'),
|
||||||
|
]);
|
||||||
|
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
|
||||||
|
if (!videoPath) {
|
||||||
|
return {
|
||||||
|
cues: appState.activeParsedSubtitleCues,
|
||||||
|
currentTimeSec,
|
||||||
|
currentSubtitle,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedSource = await resolveActiveSubtitleSidebarSource(
|
||||||
|
currentExternalFilenameRaw,
|
||||||
|
currentTrackRaw,
|
||||||
|
trackListRaw,
|
||||||
|
sidRaw,
|
||||||
|
videoPath,
|
||||||
|
);
|
||||||
|
if (!resolvedSource) {
|
||||||
|
return {
|
||||||
|
cues: appState.activeParsedSubtitleCues,
|
||||||
|
currentTimeSec,
|
||||||
|
currentSubtitle,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
appState.activeParsedSubtitleCues.length > 0 &&
|
||||||
|
appState.activeParsedSubtitleSource === resolvedSource.sourceKey
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
cues: appState.activeParsedSubtitleCues,
|
||||||
|
currentTimeSec,
|
||||||
|
currentSubtitle,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await loadSubtitleSourceText(resolvedSource.path);
|
||||||
|
const cues = parseSubtitleCues(content, resolvedSource.path);
|
||||||
|
appState.activeParsedSubtitleCues = cues;
|
||||||
|
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
|
||||||
|
return {
|
||||||
|
cues,
|
||||||
|
currentTimeSec,
|
||||||
|
currentSubtitle,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await resolvedSource.cleanup?.();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
cues: appState.activeParsedSubtitleCues,
|
||||||
|
currentTimeSec,
|
||||||
|
currentSubtitle,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
getPlaybackPaused: () => appState.playbackPaused,
|
getPlaybackPaused: () => appState.playbackPaused,
|
||||||
getSubtitlePosition: () => loadSubtitlePosition(),
|
getSubtitlePosition: () => loadSubtitlePosition(),
|
||||||
getSubtitleStyle: () => {
|
getSubtitleStyle: () => {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle'];
|
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle'];
|
||||||
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
|
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
|
||||||
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
|
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
|
||||||
|
getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot'];
|
||||||
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
|
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
|
||||||
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
|
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
|
||||||
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
|
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
|
||||||
@@ -212,6 +213,7 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
|
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
|
||||||
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
|
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
|
||||||
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
|
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
|
||||||
|
getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot,
|
||||||
getPlaybackPaused: params.getPlaybackPaused,
|
getPlaybackPaused: params.getPlaybackPaused,
|
||||||
getSubtitlePosition: params.getSubtitlePosition,
|
getSubtitlePosition: params.getSubtitlePosition,
|
||||||
getSubtitleStyle: params.getSubtitleStyle,
|
getSubtitleStyle: params.getSubtitleStyle,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
|
|||||||
return {
|
return {
|
||||||
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
|
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
|
||||||
subtitleStyle: resolveSubtitleStyleForRenderer(config),
|
subtitleStyle: resolveSubtitleStyleForRenderer(config),
|
||||||
|
subtitleSidebar: config.subtitleSidebar,
|
||||||
secondarySubMode: config.secondarySub.defaultMode,
|
secondarySubMode: config.secondarySub.defaultMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ test('media path change handler reports stop for empty path and probes media key
|
|||||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||||
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
||||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||||
getCurrentAnilistMediaKey: () => 'show:1',
|
getCurrentAnilistMediaKey: () => 'show:1',
|
||||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
||||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||||
@@ -97,6 +98,7 @@ test('media path change handler reports stop for empty path and probes media key
|
|||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'flush-playback',
|
'flush-playback',
|
||||||
'path:',
|
'path:',
|
||||||
|
'reset-sidebar-layout',
|
||||||
'stopped',
|
'stopped',
|
||||||
'restore-mpv-sub',
|
'restore-mpv-sub',
|
||||||
'reset:show:1',
|
'reset:show:1',
|
||||||
@@ -113,6 +115,7 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
|
|||||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||||
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
||||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||||
getCurrentAnilistMediaKey: () => null,
|
getCurrentAnilistMediaKey: () => null,
|
||||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
||||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||||
@@ -128,35 +131,7 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
|
|||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'path:/tmp/video.mkv',
|
'path:/tmp/video.mkv',
|
||||||
'reset:null',
|
'reset-sidebar-layout',
|
||||||
'sync',
|
|
||||||
'dict-sync',
|
|
||||||
'autoplay:/tmp/video.mkv',
|
|
||||||
'presence',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('media path change handler ignores playback flush for non-empty path', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const handler = createHandleMpvMediaPathChangeHandler({
|
|
||||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
|
||||||
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
|
||||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
|
||||||
getCurrentAnilistMediaKey: () => null,
|
|
||||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
|
||||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
|
||||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
|
||||||
syncImmersionMediaState: () => calls.push('sync'),
|
|
||||||
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
|
|
||||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
|
||||||
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
|
|
||||||
refreshDiscordPresence: () => calls.push('presence'),
|
|
||||||
});
|
|
||||||
|
|
||||||
handler({ path: '/tmp/video.mkv' });
|
|
||||||
assert.ok(!calls.includes('flush-playback'));
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'path:/tmp/video.mkv',
|
|
||||||
'reset:null',
|
'reset:null',
|
||||||
'sync',
|
'sync',
|
||||||
'dict-sync',
|
'dict-sync',
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
|||||||
updateCurrentMediaPath: (path: string) => void;
|
updateCurrentMediaPath: (path: string) => void;
|
||||||
reportJellyfinRemoteStopped: () => void;
|
reportJellyfinRemoteStopped: () => void;
|
||||||
restoreMpvSubVisibility: () => void;
|
restoreMpvSubVisibility: () => void;
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => void;
|
||||||
getCurrentAnilistMediaKey: () => string | null;
|
getCurrentAnilistMediaKey: () => string | null;
|
||||||
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
||||||
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
||||||
@@ -62,6 +63,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
|||||||
deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath);
|
deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath);
|
||||||
}
|
}
|
||||||
deps.updateCurrentMediaPath(normalizedPath);
|
deps.updateCurrentMediaPath(normalizedPath);
|
||||||
|
deps.resetSubtitleSidebarEmbeddedLayout();
|
||||||
if (!normalizedPath) {
|
if (!normalizedPath) {
|
||||||
deps.reportJellyfinRemoteStopped();
|
deps.reportJellyfinRemoteStopped();
|
||||||
deps.restoreMpvSubVisibility();
|
deps.restoreMpvSubVisibility();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
const bind = createBindMpvMainEventHandlersHandler({
|
const bind = createBindMpvMainEventHandlersHandler({
|
||||||
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
||||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||||
hasInitialJellyfinPlayArg: () => false,
|
hasInitialJellyfinPlayArg: () => false,
|
||||||
isOverlayRuntimeInitialized: () => false,
|
isOverlayRuntimeInitialized: () => false,
|
||||||
isQuitOnDisconnectArmed: () => false,
|
isQuitOnDisconnectArmed: () => false,
|
||||||
@@ -67,6 +68,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
handlers.get('connection-change')?.({ connected: true });
|
||||||
handlers.get('subtitle-change')?.({ text: 'line' });
|
handlers.get('subtitle-change')?.({ text: 'line' });
|
||||||
handlers.get('subtitle-track-change')?.({ sid: 3 });
|
handlers.get('subtitle-track-change')?.({ sid: 3 });
|
||||||
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
|
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
|
||||||
@@ -76,6 +78,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
handlers.get('pause-change')?.({ paused: true });
|
handlers.get('pause-change')?.({ paused: true });
|
||||||
|
|
||||||
assert.ok(calls.includes('set-sub:line'));
|
assert.ok(calls.includes('set-sub:line'));
|
||||||
|
assert.ok(calls.includes('reset-sidebar-layout'));
|
||||||
assert.ok(calls.includes('broadcast-sub:line'));
|
assert.ok(calls.includes('broadcast-sub:line'));
|
||||||
assert.ok(calls.includes('subtitle-change:line'));
|
assert.ok(calls.includes('subtitle-change:line'));
|
||||||
assert.ok(calls.includes('subtitle-track-change'));
|
assert.ok(calls.includes('subtitle-track-change'));
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandl
|
|||||||
export function createBindMpvMainEventHandlersHandler(deps: {
|
export function createBindMpvMainEventHandlersHandler(deps: {
|
||||||
reportJellyfinRemoteStopped: () => void;
|
reportJellyfinRemoteStopped: () => void;
|
||||||
syncOverlayMpvSubtitleSuppression: () => void;
|
syncOverlayMpvSubtitleSuppression: () => void;
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => void;
|
||||||
scheduleCharacterDictionarySync?: () => void;
|
scheduleCharacterDictionarySync?: () => void;
|
||||||
hasInitialJellyfinPlayArg: () => boolean;
|
hasInitialJellyfinPlayArg: () => boolean;
|
||||||
isOverlayRuntimeInitialized: () => boolean;
|
isOverlayRuntimeInitialized: () => boolean;
|
||||||
@@ -83,6 +84,12 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
isMpvConnected: () => deps.isMpvConnected(),
|
isMpvConnected: () => deps.isMpvConnected(),
|
||||||
quitApp: () => deps.quitApp(),
|
quitApp: () => deps.quitApp(),
|
||||||
});
|
});
|
||||||
|
const handleMpvConnectionChangeWithSidebarReset = ({ connected }: { connected: boolean }): void => {
|
||||||
|
if (connected) {
|
||||||
|
deps.resetSubtitleSidebarEmbeddedLayout();
|
||||||
|
}
|
||||||
|
handleMpvConnectionChange({ connected });
|
||||||
|
};
|
||||||
const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({
|
const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({
|
||||||
recordImmersionSubtitleLine: (text, start, end) =>
|
recordImmersionSubtitleLine: (text, start, end) =>
|
||||||
deps.recordImmersionSubtitleLine(text, start, end),
|
deps.recordImmersionSubtitleLine(text, start, end),
|
||||||
@@ -110,6 +117,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path),
|
updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path),
|
||||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||||
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout(),
|
||||||
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
||||||
resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey),
|
resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey),
|
||||||
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
|
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
|
||||||
@@ -150,7 +158,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
createBindMpvClientEventHandlers({
|
createBindMpvClientEventHandlers({
|
||||||
onConnectionChange: handleMpvConnectionChange,
|
onConnectionChange: handleMpvConnectionChangeWithSidebarReset,
|
||||||
onSubtitleChange: handleMpvSubtitleChange,
|
onSubtitleChange: handleMpvSubtitleChange,
|
||||||
onSubtitleAssChange: handleMpvSubtitleAssChange,
|
onSubtitleAssChange: handleMpvSubtitleAssChange,
|
||||||
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
|
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
|
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
|
||||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||||
getCurrentAnilistMediaKey: () => 'media-key',
|
getCurrentAnilistMediaKey: () => 'media-key',
|
||||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`),
|
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${mediaKey}`),
|
||||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||||
@@ -82,6 +83,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
deps.broadcastSecondarySubtitle('sec');
|
deps.broadcastSecondarySubtitle('sec');
|
||||||
deps.updateCurrentMediaPath('/tmp/video');
|
deps.updateCurrentMediaPath('/tmp/video');
|
||||||
deps.restoreMpvSubVisibility();
|
deps.restoreMpvSubVisibility();
|
||||||
|
deps.resetSubtitleSidebarEmbeddedLayout();
|
||||||
assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key');
|
assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key');
|
||||||
deps.resetAnilistMediaTracking('media-key');
|
deps.resetAnilistMediaTracking('media-key');
|
||||||
deps.maybeProbeAnilistDuration('media-key');
|
deps.maybeProbeAnilistDuration('media-key');
|
||||||
@@ -112,6 +114,5 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
assert.ok(calls.includes('metrics'));
|
assert.ok(calls.includes('metrics'));
|
||||||
assert.ok(calls.includes('presence-refresh'));
|
assert.ok(calls.includes('presence-refresh'));
|
||||||
assert.ok(calls.includes('restore-mpv-sub'));
|
assert.ok(calls.includes('restore-mpv-sub'));
|
||||||
assert.ok(calls.includes('immersion-time:12.25'));
|
assert.ok(calls.includes('reset-sidebar-layout'));
|
||||||
assert.ok(calls.includes('immersion-time:18.75'));
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||||
updateCurrentMediaPath: (path: string) => void;
|
updateCurrentMediaPath: (path: string) => void;
|
||||||
restoreMpvSubVisibility: () => void;
|
restoreMpvSubVisibility: () => void;
|
||||||
|
resetSubtitleSidebarEmbeddedLayout?: () => void;
|
||||||
getCurrentAnilistMediaKey: () => string | null;
|
getCurrentAnilistMediaKey: () => string | null;
|
||||||
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
||||||
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
||||||
@@ -146,6 +147,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
|
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
|
||||||
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
|
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
|
||||||
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout?.(),
|
||||||
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
||||||
resetAnilistMediaTracking: (mediaKey: string | null) =>
|
resetAnilistMediaTracking: (mediaKey: string | null) =>
|
||||||
deps.resetAnilistMediaTracking(mediaKey),
|
deps.resetAnilistMediaTracking(mediaKey),
|
||||||
|
|||||||
@@ -112,3 +112,90 @@ test('cancelPendingInit prevents an in-flight load from attaching a stale servic
|
|||||||
assert.equal(currentService, null);
|
assert.equal(currentService, null);
|
||||||
assert.deepEqual(started, []);
|
assert.deepEqual(started, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitle prefetch init publishes parsed cues and clears them on cancel', async () => {
|
||||||
|
const deferred = createDeferred<string>();
|
||||||
|
let currentService: SubtitlePrefetchService | null = null;
|
||||||
|
const cueUpdates: Array<SubtitleCue[] | null> = [];
|
||||||
|
|
||||||
|
const controller = createSubtitlePrefetchInitController({
|
||||||
|
getCurrentService: () => currentService,
|
||||||
|
setCurrentService: (service) => {
|
||||||
|
currentService = service;
|
||||||
|
},
|
||||||
|
loadSubtitleSourceText: async () => await deferred.promise,
|
||||||
|
parseSubtitleCues: () => [
|
||||||
|
{ startTime: 1, endTime: 2, text: 'first' },
|
||||||
|
{ startTime: 3, endTime: 4, text: 'second' },
|
||||||
|
],
|
||||||
|
createSubtitlePrefetchService: () => ({
|
||||||
|
start: () => {},
|
||||||
|
stop: () => {},
|
||||||
|
onSeek: () => {},
|
||||||
|
pause: () => {},
|
||||||
|
resume: () => {},
|
||||||
|
}),
|
||||||
|
tokenizeSubtitle: async () => null,
|
||||||
|
preCacheTokenization: () => {},
|
||||||
|
isCacheFull: () => false,
|
||||||
|
logInfo: () => {},
|
||||||
|
logWarn: () => {},
|
||||||
|
onParsedSubtitleCuesChanged: (cues) => {
|
||||||
|
cueUpdates.push(cues);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const initPromise = controller.initSubtitlePrefetch('episode.ass', 12);
|
||||||
|
deferred.resolve('content');
|
||||||
|
await initPromise;
|
||||||
|
|
||||||
|
controller.cancelPendingInit();
|
||||||
|
|
||||||
|
assert.deepEqual(cueUpdates, [
|
||||||
|
[
|
||||||
|
{ startTime: 1, endTime: 2, text: 'first' },
|
||||||
|
{ startTime: 3, endTime: 4, text: 'second' },
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle prefetch init publishes the provided stable source key instead of the load path', async () => {
|
||||||
|
const deferred = createDeferred<string>();
|
||||||
|
let currentService: SubtitlePrefetchService | null = null;
|
||||||
|
const sourceUpdates: Array<string | null> = [];
|
||||||
|
|
||||||
|
const controller = createSubtitlePrefetchInitController({
|
||||||
|
getCurrentService: () => currentService,
|
||||||
|
setCurrentService: (service) => {
|
||||||
|
currentService = service;
|
||||||
|
},
|
||||||
|
loadSubtitleSourceText: async () => await deferred.promise,
|
||||||
|
parseSubtitleCues: () => [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||||
|
createSubtitlePrefetchService: () => ({
|
||||||
|
start: () => {},
|
||||||
|
stop: () => {},
|
||||||
|
onSeek: () => {},
|
||||||
|
pause: () => {},
|
||||||
|
resume: () => {},
|
||||||
|
}),
|
||||||
|
tokenizeSubtitle: async () => null,
|
||||||
|
preCacheTokenization: () => {},
|
||||||
|
isCacheFull: () => false,
|
||||||
|
logInfo: () => {},
|
||||||
|
logWarn: () => {},
|
||||||
|
onParsedSubtitleCuesChanged: (_cues, source) => {
|
||||||
|
sourceUpdates.push(source);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const initPromise = controller.initSubtitlePrefetch(
|
||||||
|
'/tmp/subminer-sidebar-123/track_7.ass',
|
||||||
|
12,
|
||||||
|
'internal:/media/episode01.mkv:track:3:ff:7',
|
||||||
|
);
|
||||||
|
deferred.resolve('content');
|
||||||
|
await initPromise;
|
||||||
|
|
||||||
|
assert.deepEqual(sourceUpdates, ['internal:/media/episode01.mkv:track:3:ff:7']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,11 +16,16 @@ export interface SubtitlePrefetchInitControllerDeps {
|
|||||||
isCacheFull: () => boolean;
|
isCacheFull: () => boolean;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
logWarn: (message: string) => void;
|
logWarn: (message: string) => void;
|
||||||
|
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubtitlePrefetchInitController {
|
export interface SubtitlePrefetchInitController {
|
||||||
cancelPendingInit: () => void;
|
cancelPendingInit: () => void;
|
||||||
initSubtitlePrefetch: (externalFilename: string, currentTimePos: number) => Promise<void>;
|
initSubtitlePrefetch: (
|
||||||
|
sourcePath: string,
|
||||||
|
currentTimePos: number,
|
||||||
|
sourceKey?: string,
|
||||||
|
) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSubtitlePrefetchInitController(
|
export function createSubtitlePrefetchInitController(
|
||||||
@@ -32,24 +37,29 @@ export function createSubtitlePrefetchInitController(
|
|||||||
initRevision += 1;
|
initRevision += 1;
|
||||||
deps.getCurrentService()?.stop();
|
deps.getCurrentService()?.stop();
|
||||||
deps.setCurrentService(null);
|
deps.setCurrentService(null);
|
||||||
|
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const initSubtitlePrefetch = async (
|
const initSubtitlePrefetch = async (
|
||||||
externalFilename: string,
|
sourcePath: string,
|
||||||
currentTimePos: number,
|
currentTimePos: number,
|
||||||
|
sourceKey = sourcePath,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const revision = ++initRevision;
|
const revision = ++initRevision;
|
||||||
deps.getCurrentService()?.stop();
|
deps.getCurrentService()?.stop();
|
||||||
deps.setCurrentService(null);
|
deps.setCurrentService(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await deps.loadSubtitleSourceText(externalFilename);
|
const content = await deps.loadSubtitleSourceText(sourcePath);
|
||||||
if (revision !== initRevision) {
|
if (revision !== initRevision) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cues = deps.parseSubtitleCues(content, externalFilename);
|
const cues = deps.parseSubtitleCues(content, sourcePath);
|
||||||
if (revision !== initRevision || cues.length === 0) {
|
if (revision !== initRevision || cues.length === 0) {
|
||||||
|
if (revision === initRevision) {
|
||||||
|
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,9 +75,10 @@ export function createSubtitlePrefetchInitController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
deps.setCurrentService(nextService);
|
deps.setCurrentService(nextService);
|
||||||
|
deps.onParsedSubtitleCuesChanged?.(cues, sourceKey);
|
||||||
nextService.start(currentTimePos);
|
nextService.start(currentTimePos);
|
||||||
deps.logInfo(
|
deps.logInfo(
|
||||||
`[subtitle-prefetch] started prefetching ${cues.length} cues from ${externalFilename}`,
|
`[subtitle-prefetch] started prefetching ${cues.length} cues from ${sourcePath}`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (revision === initRevision) {
|
if (revision === initRevision) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
|
buildSubtitleSidebarSourceKey,
|
||||||
getActiveExternalSubtitleSource,
|
getActiveExternalSubtitleSource,
|
||||||
resolveSubtitleSourcePath,
|
resolveSubtitleSourcePath,
|
||||||
} from './subtitle-prefetch-source';
|
} from './subtitle-prefetch-source';
|
||||||
@@ -48,3 +49,29 @@ test('resolveSubtitleSourcePath returns the original source for malformed file U
|
|||||||
|
|
||||||
assert.equal(resolveSubtitleSourcePath(source), source);
|
assert.equal(resolveSubtitleSourcePath(source), source);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildSubtitleSidebarSourceKey uses a stable identifier for internal subtitle tracks', () => {
|
||||||
|
const firstKey = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
|
||||||
|
id: 3,
|
||||||
|
'ff-index': 7,
|
||||||
|
title: 'English',
|
||||||
|
lang: 'eng',
|
||||||
|
codec: 'ass',
|
||||||
|
});
|
||||||
|
const secondKey = buildSubtitleSidebarSourceKey('/media/episode01.mkv', {
|
||||||
|
id: 3,
|
||||||
|
'ff-index': 7,
|
||||||
|
title: 'English',
|
||||||
|
lang: 'eng',
|
||||||
|
codec: 'ass',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(firstKey, secondKey);
|
||||||
|
assert.equal(firstKey, 'internal:/media/episode01.mkv:track:3:ff:7');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSubtitleSidebarSourceKey falls back to source path when no track metadata is available', () => {
|
||||||
|
const key = buildSubtitleSidebarSourceKey('/media/episode01.mkv', null, '/tmp/subtitle.ass');
|
||||||
|
|
||||||
|
assert.equal(key, '/tmp/subtitle.ass');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
function parseTrackId(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value.trim());
|
||||||
|
return Number.isInteger(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getActiveExternalSubtitleSource(
|
export function getActiveExternalSubtitleSource(
|
||||||
trackListRaw: unknown,
|
trackListRaw: unknown,
|
||||||
sidRaw: unknown,
|
sidRaw: unknown,
|
||||||
@@ -19,7 +30,7 @@ export function getActiveExternalSubtitleSource(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const track = entry as Record<string, unknown>;
|
const track = entry as Record<string, unknown>;
|
||||||
return track.type === 'sub' && track.id === sid && track.external === true;
|
return track.type === 'sub' && track.id === sid;
|
||||||
}) as Record<string, unknown> | undefined;
|
}) as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
const externalFilename =
|
const externalFilename =
|
||||||
@@ -40,3 +51,21 @@ export function resolveSubtitleSourcePath(source: string): string {
|
|||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildSubtitleSidebarSourceKey(
|
||||||
|
videoPath: string,
|
||||||
|
track: unknown,
|
||||||
|
fallbackSourcePath?: string,
|
||||||
|
): string {
|
||||||
|
const normalizedVideoPath = videoPath.trim();
|
||||||
|
if (track && typeof track === 'object' && normalizedVideoPath) {
|
||||||
|
const subtitleTrack = track as Record<string, unknown>;
|
||||||
|
const trackId = parseTrackId(subtitleTrack.id);
|
||||||
|
const ffIndex = parseTrackId(subtitleTrack['ff-index']);
|
||||||
|
if (trackId !== null || ffIndex !== null) {
|
||||||
|
return `internal:${normalizedVideoPath}:track:${trackId ?? 'unknown'}:ff:${ffIndex ?? 'unknown'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackSourcePath ?? normalizedVideoPath;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
MpvSubtitleRenderMetrics,
|
MpvSubtitleRenderMetrics,
|
||||||
SecondarySubMode,
|
SecondarySubMode,
|
||||||
SubtitleData,
|
SubtitleData,
|
||||||
|
SubtitleCue,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
KikuFieldGroupingChoice,
|
KikuFieldGroupingChoice,
|
||||||
JlptLevel,
|
JlptLevel,
|
||||||
@@ -158,6 +159,8 @@ export interface AppState {
|
|||||||
currentSubText: string;
|
currentSubText: string;
|
||||||
currentSubAssText: string;
|
currentSubAssText: string;
|
||||||
currentSubtitleData: SubtitleData | null;
|
currentSubtitleData: SubtitleData | null;
|
||||||
|
activeParsedSubtitleCues: SubtitleCue[];
|
||||||
|
activeParsedSubtitleSource: string | null;
|
||||||
windowTracker: BaseWindowTracker | null;
|
windowTracker: BaseWindowTracker | null;
|
||||||
subtitlePosition: SubtitlePosition | null;
|
subtitlePosition: SubtitlePosition | null;
|
||||||
currentMediaPath: string | null;
|
currentMediaPath: string | null;
|
||||||
@@ -238,6 +241,8 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
|||||||
currentSubText: '',
|
currentSubText: '',
|
||||||
currentSubAssText: '',
|
currentSubAssText: '',
|
||||||
currentSubtitleData: null,
|
currentSubtitleData: null,
|
||||||
|
activeParsedSubtitleCues: [],
|
||||||
|
activeParsedSubtitleSource: null,
|
||||||
windowTracker: null,
|
windowTracker: null,
|
||||||
subtitlePosition: null,
|
subtitlePosition: null,
|
||||||
currentMediaPath: null,
|
currentMediaPath: null,
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ const electronAPI: ElectronAPI = {
|
|||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw),
|
||||||
getCurrentSubtitleAss: (): Promise<string> =>
|
getCurrentSubtitleAss: (): Promise<string> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss),
|
||||||
|
getSubtitleSidebarSnapshot: () =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot),
|
||||||
getPlaybackPaused: (): Promise<boolean | null> =>
|
getPlaybackPaused: (): Promise<boolean | null> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused),
|
||||||
onSubtitleAss: (callback: (assText: string) => void) => {
|
onSubtitleAss: (callback: (assText: string) => void) => {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function createKeyboardHandlers(
|
|||||||
getPlaybackPaused: () => Promise<boolean | null>;
|
getPlaybackPaused: () => Promise<boolean | null>;
|
||||||
openControllerSelectModal: () => void;
|
openControllerSelectModal: () => void;
|
||||||
openControllerDebugModal: () => void;
|
openControllerDebugModal: () => void;
|
||||||
|
toggleSubtitleSidebarModal?: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||||
@@ -181,6 +182,26 @@ export function createKeyboardHandlers(
|
|||||||
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
|
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSubtitleSidebarToggle(e: KeyboardEvent): boolean {
|
||||||
|
const toggleKey = ctx.state.subtitleSidebarToggleKey;
|
||||||
|
if (!toggleKey) return false;
|
||||||
|
const isBackslashConfigured = toggleKey === 'Backslash' || toggleKey === '\\';
|
||||||
|
const isBackslashLikeCode = ['Backslash', 'IntlBackslash', 'IntlYen'].includes(e.code);
|
||||||
|
const keyMatches =
|
||||||
|
toggleKey === e.code ||
|
||||||
|
(isBackslashConfigured && isBackslashLikeCode) ||
|
||||||
|
(isBackslashConfigured && e.key === '\\') ||
|
||||||
|
(toggleKey.length === 1 && e.key === toggleKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
keyMatches &&
|
||||||
|
!e.ctrlKey &&
|
||||||
|
!e.altKey &&
|
||||||
|
!e.metaKey &&
|
||||||
|
!e.repeat
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isStatsOverlayToggle(e: KeyboardEvent): boolean {
|
function isStatsOverlayToggle(e: KeyboardEvent): boolean {
|
||||||
return (
|
return (
|
||||||
e.code === ctx.state.statsToggleKey &&
|
e.code === ctx.state.statsToggleKey &&
|
||||||
@@ -838,6 +859,12 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSubtitleSidebarToggle(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
options.toggleSubtitleSidebarModal?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
|
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
|
||||||
!isControllerModalShortcut(e)
|
!isControllerModalShortcut(e)
|
||||||
|
|||||||
@@ -256,6 +256,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="subtitleSidebarModal" class="modal hidden subtitle-sidebar-modal" aria-hidden="true">
|
||||||
|
<div id="subtitleSidebarContent" class="modal-content subtitle-sidebar-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title">Subtitle Sidebar</div>
|
||||||
|
<button id="subtitleSidebarClose" class="modal-close" type="button">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body subtitle-sidebar-body">
|
||||||
|
<div id="subtitleSidebarStatus" class="runtime-options-status"></div>
|
||||||
|
<ul id="subtitleSidebarList" class="subtitle-sidebar-list"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
|
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
|
||||||
<div class="modal-content session-help-content">
|
<div class="modal-content session-help-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|||||||
622
src/renderer/modals/subtitle-sidebar.test.ts
Normal file
622
src/renderer/modals/subtitle-sidebar.test.ts
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types';
|
||||||
|
import { createRendererState } from '../state.js';
|
||||||
|
import { createSubtitleSidebarModal, findActiveSubtitleCueIndex } from './subtitle-sidebar.js';
|
||||||
|
|
||||||
|
function createClassList(initialTokens: string[] = []) {
|
||||||
|
const tokens = new Set(initialTokens);
|
||||||
|
return {
|
||||||
|
add: (...entries: string[]) => {
|
||||||
|
for (const entry of entries) tokens.add(entry);
|
||||||
|
},
|
||||||
|
remove: (...entries: string[]) => {
|
||||||
|
for (const entry of entries) tokens.delete(entry);
|
||||||
|
},
|
||||||
|
contains: (entry: string) => tokens.has(entry),
|
||||||
|
toggle: (entry: string, force?: boolean) => {
|
||||||
|
if (force === true) tokens.add(entry);
|
||||||
|
else if (force === false) tokens.delete(entry);
|
||||||
|
else if (tokens.has(entry)) tokens.delete(entry);
|
||||||
|
else tokens.add(entry);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCueRow() {
|
||||||
|
return {
|
||||||
|
className: '',
|
||||||
|
classList: createClassList(),
|
||||||
|
dataset: {} as Record<string, string>,
|
||||||
|
textContent: '',
|
||||||
|
offsetTop: 0,
|
||||||
|
clientHeight: 40,
|
||||||
|
children: [] as unknown[],
|
||||||
|
appendChild(child: unknown) {
|
||||||
|
this.children.push(child);
|
||||||
|
},
|
||||||
|
addEventListener: () => {},
|
||||||
|
scrollIntoViewCalls: [] as ScrollIntoViewOptions[],
|
||||||
|
scrollIntoView(options?: ScrollIntoViewOptions) {
|
||||||
|
this.scrollIntoViewCalls.push(options ?? {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createListStub() {
|
||||||
|
return {
|
||||||
|
innerHTML: '',
|
||||||
|
children: [] as ReturnType<typeof createCueRow>[],
|
||||||
|
appendChild(child: ReturnType<typeof createCueRow>) {
|
||||||
|
child.offsetTop = this.children.length * child.clientHeight;
|
||||||
|
this.children.push(child);
|
||||||
|
},
|
||||||
|
addEventListener: () => {},
|
||||||
|
scrollTop: 0,
|
||||||
|
clientHeight: 240,
|
||||||
|
scrollHeight: 480,
|
||||||
|
scrollToCalls: [] as ScrollToOptions[],
|
||||||
|
scrollTo(options?: ScrollToOptions) {
|
||||||
|
this.scrollToCalls.push(options ?? {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('findActiveSubtitleCueIndex prefers timing match before text fallback', () => {
|
||||||
|
const cues = [
|
||||||
|
{ startTime: 1, endTime: 2, text: 'same' },
|
||||||
|
{ startTime: 3, endTime: 4, text: 'same' },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'same', startTime: 3.1 }), 1);
|
||||||
|
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'same', startTime: null }), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback', async () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
const previousDocument = globals.document;
|
||||||
|
const mpvCommands: Array<Array<string | number>> = [];
|
||||||
|
|
||||||
|
const snapshot: SubtitleSidebarSnapshot = {
|
||||||
|
cues: [
|
||||||
|
{ startTime: 1, endTime: 2, text: 'first' },
|
||||||
|
{ startTime: 3, endTime: 4, text: 'second' },
|
||||||
|
],
|
||||||
|
currentSubtitle: {
|
||||||
|
text: 'second',
|
||||||
|
startTime: 3,
|
||||||
|
endTime: 4,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
layout: 'overlay',
|
||||||
|
toggleKey: 'Backslash',
|
||||||
|
pauseVideoOnHover: false,
|
||||||
|
autoScroll: true,
|
||||||
|
maxWidth: 420,
|
||||||
|
opacity: 0.92,
|
||||||
|
backgroundColor: 'rgba(54, 58, 79, 0.88)',
|
||||||
|
textColor: '#cad3f5',
|
||||||
|
fontFamily: '"Iosevka Aile", sans-serif',
|
||||||
|
fontSize: 17,
|
||||||
|
timestampColor: '#a5adcb',
|
||||||
|
activeLineColor: '#f5bde6',
|
||||||
|
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||||
|
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||||
|
sendMpvCommand: (command: Array<string | number>) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
} as unknown as ElectronAPI,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createCueRow(),
|
||||||
|
body: {
|
||||||
|
classList: createClassList(),
|
||||||
|
},
|
||||||
|
documentElement: {
|
||||||
|
style: {
|
||||||
|
setProperty: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
const overlayClassList = createClassList();
|
||||||
|
const modalClassList = createClassList(['hidden']);
|
||||||
|
const cueList = createListStub();
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: overlayClassList },
|
||||||
|
subtitleSidebarModal: {
|
||||||
|
classList: modalClassList,
|
||||||
|
setAttribute: () => {},
|
||||||
|
style: { setProperty: () => {} },
|
||||||
|
addEventListener: () => {},
|
||||||
|
},
|
||||||
|
subtitleSidebarContent: {
|
||||||
|
classList: createClassList(),
|
||||||
|
getBoundingClientRect: () => ({ width: 420 }),
|
||||||
|
},
|
||||||
|
subtitleSidebarClose: { addEventListener: () => {} },
|
||||||
|
subtitleSidebarStatus: { textContent: '' },
|
||||||
|
subtitleSidebarList: cueList,
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createSubtitleSidebarModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await modal.openSubtitleSidebarModal();
|
||||||
|
|
||||||
|
assert.equal(state.subtitleSidebarModalOpen, true);
|
||||||
|
assert.equal(modalClassList.contains('hidden'), false);
|
||||||
|
assert.equal(state.subtitleSidebarActiveCueIndex, 1);
|
||||||
|
assert.equal(cueList.children.length, 2);
|
||||||
|
assert.deepEqual(cueList.scrollToCalls[0], {
|
||||||
|
top: 0,
|
||||||
|
behavior: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.seekToCue(snapshot.cues[0]!);
|
||||||
|
assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle sidebar keeps nearby repeated cue when subtitle update lacks timing', async () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
const previousDocument = globals.document;
|
||||||
|
|
||||||
|
const snapshot: SubtitleSidebarSnapshot = {
|
||||||
|
cues: [
|
||||||
|
{ startTime: 1, endTime: 2, text: 'same' },
|
||||||
|
{ startTime: 3, endTime: 4, text: 'other' },
|
||||||
|
{ startTime: 10, endTime: 11, text: 'same' },
|
||||||
|
],
|
||||||
|
currentSubtitle: {
|
||||||
|
text: 'same',
|
||||||
|
startTime: 10,
|
||||||
|
endTime: 11,
|
||||||
|
},
|
||||||
|
currentTimeSec: 10.1,
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
layout: 'overlay',
|
||||||
|
toggleKey: 'Backslash',
|
||||||
|
pauseVideoOnHover: false,
|
||||||
|
autoScroll: true,
|
||||||
|
maxWidth: 420,
|
||||||
|
opacity: 0.92,
|
||||||
|
backgroundColor: 'rgba(54, 58, 79, 0.88)',
|
||||||
|
textColor: '#cad3f5',
|
||||||
|
fontFamily: '"Iosevka Aile", sans-serif',
|
||||||
|
fontSize: 17,
|
||||||
|
timestampColor: '#a5adcb',
|
||||||
|
activeLineColor: '#f5bde6',
|
||||||
|
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||||
|
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
} as unknown as ElectronAPI,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createCueRow(),
|
||||||
|
body: {
|
||||||
|
classList: createClassList(),
|
||||||
|
},
|
||||||
|
documentElement: {
|
||||||
|
style: {
|
||||||
|
setProperty: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
const cueList = createListStub();
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList() },
|
||||||
|
subtitleSidebarModal: {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
setAttribute: () => {},
|
||||||
|
style: { setProperty: () => {} },
|
||||||
|
addEventListener: () => {},
|
||||||
|
},
|
||||||
|
subtitleSidebarContent: {
|
||||||
|
classList: createClassList(),
|
||||||
|
getBoundingClientRect: () => ({ width: 420 }),
|
||||||
|
},
|
||||||
|
subtitleSidebarClose: { addEventListener: () => {} },
|
||||||
|
subtitleSidebarStatus: { textContent: '' },
|
||||||
|
subtitleSidebarList: cueList,
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createSubtitleSidebarModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await modal.openSubtitleSidebarModal();
|
||||||
|
cueList.scrollToCalls.length = 0;
|
||||||
|
|
||||||
|
modal.handleSubtitleUpdated({
|
||||||
|
text: 'same',
|
||||||
|
startTime: null,
|
||||||
|
endTime: null,
|
||||||
|
tokens: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(state.subtitleSidebarActiveCueIndex, 2);
|
||||||
|
assert.deepEqual(cueList.scrollToCalls, []);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle sidebar does not regress to previous cue on text-only transition update', async () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
const previousDocument = globals.document;
|
||||||
|
|
||||||
|
const snapshot: SubtitleSidebarSnapshot = {
|
||||||
|
cues: [
|
||||||
|
{ startTime: 1, endTime: 2, text: 'first' },
|
||||||
|
{ startTime: 3, endTime: 4, text: 'second' },
|
||||||
|
{ startTime: 5, endTime: 6, text: 'third' },
|
||||||
|
],
|
||||||
|
currentSubtitle: {
|
||||||
|
text: 'third',
|
||||||
|
startTime: 5,
|
||||||
|
endTime: 6,
|
||||||
|
},
|
||||||
|
currentTimeSec: 5.1,
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
layout: 'overlay',
|
||||||
|
toggleKey: 'Backslash',
|
||||||
|
pauseVideoOnHover: false,
|
||||||
|
autoScroll: true,
|
||||||
|
maxWidth: 420,
|
||||||
|
opacity: 0.92,
|
||||||
|
backgroundColor: 'rgba(54, 58, 79, 0.88)',
|
||||||
|
textColor: '#cad3f5',
|
||||||
|
fontFamily: '"Iosevka Aile", sans-serif',
|
||||||
|
fontSize: 17,
|
||||||
|
timestampColor: '#a5adcb',
|
||||||
|
activeLineColor: '#f5bde6',
|
||||||
|
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||||
|
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
} as unknown as ElectronAPI,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createCueRow(),
|
||||||
|
body: {
|
||||||
|
classList: createClassList(),
|
||||||
|
},
|
||||||
|
documentElement: {
|
||||||
|
style: {
|
||||||
|
setProperty: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
const cueList = createListStub();
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList() },
|
||||||
|
subtitleSidebarModal: {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
setAttribute: () => {},
|
||||||
|
style: { setProperty: () => {} },
|
||||||
|
addEventListener: () => {},
|
||||||
|
},
|
||||||
|
subtitleSidebarContent: {
|
||||||
|
classList: createClassList(),
|
||||||
|
getBoundingClientRect: () => ({ width: 420 }),
|
||||||
|
},
|
||||||
|
subtitleSidebarClose: { addEventListener: () => {} },
|
||||||
|
subtitleSidebarStatus: { textContent: '' },
|
||||||
|
subtitleSidebarList: cueList,
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createSubtitleSidebarModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await modal.openSubtitleSidebarModal();
|
||||||
|
cueList.scrollToCalls.length = 0;
|
||||||
|
|
||||||
|
modal.handleSubtitleUpdated({
|
||||||
|
text: 'second',
|
||||||
|
startTime: null,
|
||||||
|
endTime: null,
|
||||||
|
tokens: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(state.subtitleSidebarActiveCueIndex, 2);
|
||||||
|
assert.deepEqual(cueList.scrollToCalls, []);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle sidebar embedded layout reserves and releases mpv right margin', async () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
const previousDocument = globals.document;
|
||||||
|
const mpvCommands: Array<Array<string | number>> = [];
|
||||||
|
|
||||||
|
const snapshot: SubtitleSidebarSnapshot = {
|
||||||
|
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||||
|
currentSubtitle: {
|
||||||
|
text: 'first',
|
||||||
|
startTime: 1,
|
||||||
|
endTime: 2,
|
||||||
|
},
|
||||||
|
currentTimeSec: 1.1,
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
layout: 'embedded',
|
||||||
|
toggleKey: 'Backslash',
|
||||||
|
pauseVideoOnHover: false,
|
||||||
|
autoScroll: true,
|
||||||
|
maxWidth: 360,
|
||||||
|
opacity: 0.92,
|
||||||
|
backgroundColor: 'rgba(54, 58, 79, 0.88)',
|
||||||
|
textColor: '#cad3f5',
|
||||||
|
fontFamily: '"Iosevka Aile", sans-serif',
|
||||||
|
fontSize: 17,
|
||||||
|
timestampColor: '#a5adcb',
|
||||||
|
activeLineColor: '#f5bde6',
|
||||||
|
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||||
|
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootStyleCalls: Array<[string, string]> = [];
|
||||||
|
const bodyClassList = createClassList();
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
innerWidth: 1200,
|
||||||
|
electronAPI: {
|
||||||
|
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||||
|
sendMpvCommand: (command: Array<string | number>) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
} as unknown as ElectronAPI,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createCueRow(),
|
||||||
|
body: {
|
||||||
|
classList: bodyClassList,
|
||||||
|
},
|
||||||
|
documentElement: {
|
||||||
|
style: {
|
||||||
|
setProperty: (name: string, value: string) => {
|
||||||
|
rootStyleCalls.push([name, value]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
const cueList = createListStub();
|
||||||
|
const modalClassList = createClassList(['hidden']);
|
||||||
|
const contentClassList = createClassList();
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList() },
|
||||||
|
subtitleSidebarModal: {
|
||||||
|
classList: modalClassList,
|
||||||
|
setAttribute: () => {},
|
||||||
|
style: { setProperty: () => {} },
|
||||||
|
addEventListener: () => {},
|
||||||
|
},
|
||||||
|
subtitleSidebarContent: {
|
||||||
|
classList: contentClassList,
|
||||||
|
getBoundingClientRect: () => ({ width: 360 }),
|
||||||
|
},
|
||||||
|
subtitleSidebarClose: { addEventListener: () => {} },
|
||||||
|
subtitleSidebarStatus: { textContent: '' },
|
||||||
|
subtitleSidebarList: cueList,
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createSubtitleSidebarModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await modal.openSubtitleSidebarModal();
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
mpvCommands.some(
|
||||||
|
(command) =>
|
||||||
|
command[0] === 'set_property' &&
|
||||||
|
command[1] === 'video-margin-ratio-right' &&
|
||||||
|
command[2] === 0.3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(bodyClassList.contains('subtitle-sidebar-embedded-open'));
|
||||||
|
assert.ok(
|
||||||
|
rootStyleCalls.some(
|
||||||
|
([name, value]) => name === '--subtitle-sidebar-reserved-width' && value === '360px',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.closeSubtitleSidebarModal();
|
||||||
|
|
||||||
|
assert.deepEqual(mpvCommands.at(-2), ['set_property', 'video-margin-ratio-right', 0]);
|
||||||
|
assert.deepEqual(mpvCommands.at(-1), ['set_property', 'video-pan-x', 0]);
|
||||||
|
assert.equal(bodyClassList.contains('subtitle-sidebar-embedded-open'), false);
|
||||||
|
assert.deepEqual(rootStyleCalls.at(-1), ['--subtitle-sidebar-reserved-width', '0px']);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle sidebar resets embedded mpv margin on startup while closed', async () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
const previousDocument = globals.document;
|
||||||
|
const mpvCommands: Array<Array<string | number>> = [];
|
||||||
|
|
||||||
|
const snapshot: SubtitleSidebarSnapshot = {
|
||||||
|
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||||
|
currentSubtitle: {
|
||||||
|
text: 'first',
|
||||||
|
startTime: 1,
|
||||||
|
endTime: 2,
|
||||||
|
},
|
||||||
|
currentTimeSec: 1.1,
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
layout: 'embedded',
|
||||||
|
toggleKey: 'Backslash',
|
||||||
|
pauseVideoOnHover: false,
|
||||||
|
autoScroll: true,
|
||||||
|
maxWidth: 360,
|
||||||
|
opacity: 0.92,
|
||||||
|
backgroundColor: 'rgba(54, 58, 79, 0.88)',
|
||||||
|
textColor: '#cad3f5',
|
||||||
|
fontFamily: '"Iosevka Aile", sans-serif',
|
||||||
|
fontSize: 17,
|
||||||
|
timestampColor: '#a5adcb',
|
||||||
|
activeLineColor: '#f5bde6',
|
||||||
|
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||||
|
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
innerWidth: 1200,
|
||||||
|
electronAPI: {
|
||||||
|
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||||
|
sendMpvCommand: (command: Array<string | number>) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
} as unknown as ElectronAPI,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createCueRow(),
|
||||||
|
body: {
|
||||||
|
classList: createClassList(),
|
||||||
|
},
|
||||||
|
documentElement: {
|
||||||
|
style: {
|
||||||
|
setProperty: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList() },
|
||||||
|
subtitleSidebarModal: {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
setAttribute: () => {},
|
||||||
|
style: { setProperty: () => {} },
|
||||||
|
addEventListener: () => {},
|
||||||
|
},
|
||||||
|
subtitleSidebarContent: {
|
||||||
|
classList: createClassList(),
|
||||||
|
getBoundingClientRect: () => ({ width: 360 }),
|
||||||
|
},
|
||||||
|
subtitleSidebarClose: { addEventListener: () => {} },
|
||||||
|
subtitleSidebarStatus: { textContent: '' },
|
||||||
|
subtitleSidebarList: createListStub(),
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createSubtitleSidebarModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await modal.refreshSubtitleSidebarSnapshot();
|
||||||
|
|
||||||
|
assert.deepEqual(mpvCommands, [
|
||||||
|
['set_property', 'video-margin-ratio-right', 0],
|
||||||
|
['set_property', 'video-pan-x', 0],
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
461
src/renderer/modals/subtitle-sidebar.ts
Normal file
461
src/renderer/modals/subtitle-sidebar.ts
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
import type {
|
||||||
|
SubtitleCue,
|
||||||
|
SubtitleData,
|
||||||
|
SubtitleSidebarSnapshot,
|
||||||
|
} from '../../types';
|
||||||
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
|
|
||||||
|
const MANUAL_SCROLL_HOLD_MS = 1500;
|
||||||
|
const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18;
|
||||||
|
const CLICK_SEEK_OFFSET_SEC = 0.08;
|
||||||
|
const SNAPSHOT_POLL_INTERVAL_MS = 80;
|
||||||
|
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
|
||||||
|
const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45;
|
||||||
|
|
||||||
|
function subtitleCueListsEqual(a: SubtitleCue[], b: SubtitleCue[]): boolean {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < a.length; i += 1) {
|
||||||
|
const left = a[i]!;
|
||||||
|
const right = b[i]!;
|
||||||
|
if (
|
||||||
|
left.startTime !== right.startTime ||
|
||||||
|
left.endTime !== right.endTime ||
|
||||||
|
left.text !== right.text
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCueText(text: string): string {
|
||||||
|
return text.replace(/\r\n/g, '\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCueTimestamp(seconds: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(seconds));
|
||||||
|
const mins = Math.floor(totalSeconds / 60);
|
||||||
|
const secs = totalSeconds % 60;
|
||||||
|
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findActiveSubtitleCueIndex(
|
||||||
|
cues: SubtitleCue[],
|
||||||
|
current: { text: string; startTime?: number | null } | null,
|
||||||
|
currentTimeSec: number | null = null,
|
||||||
|
preferredCueIndex: number = -1,
|
||||||
|
): number {
|
||||||
|
if (cues.length === 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof currentTimeSec === 'number' && Number.isFinite(currentTimeSec)) {
|
||||||
|
const activeOrUpcomingCue = cues.findIndex(
|
||||||
|
(cue) =>
|
||||||
|
cue.endTime > currentTimeSec &&
|
||||||
|
cue.startTime <= currentTimeSec + ACTIVE_CUE_LOOKAHEAD_SEC,
|
||||||
|
);
|
||||||
|
if (activeOrUpcomingCue >= 0) {
|
||||||
|
return activeOrUpcomingCue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCue = cues.findIndex((cue) => cue.endTime > currentTimeSec);
|
||||||
|
if (nextCue >= 0) {
|
||||||
|
return nextCue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof current.startTime === 'number' && Number.isFinite(current.startTime)) {
|
||||||
|
const timingMatch = cues.findIndex(
|
||||||
|
(cue) => current.startTime! >= cue.startTime && current.startTime! < cue.endTime,
|
||||||
|
);
|
||||||
|
if (timingMatch >= 0) {
|
||||||
|
return timingMatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedText = normalizeCueText(current.text);
|
||||||
|
if (!normalizedText) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingIndices: number[] = [];
|
||||||
|
for (const [index, cue] of cues.entries()) {
|
||||||
|
if (normalizeCueText(cue.text) === normalizedText) {
|
||||||
|
matchingIndices.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingIndices.length === 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTiming =
|
||||||
|
typeof current.startTime === 'number' && Number.isFinite(current.startTime);
|
||||||
|
|
||||||
|
if (preferredCueIndex >= 0) {
|
||||||
|
if (!hasTiming && currentTimeSec === null) {
|
||||||
|
const forwardMatches = matchingIndices.filter((index) => index >= preferredCueIndex);
|
||||||
|
if (forwardMatches.length > 0) {
|
||||||
|
return forwardMatches[0]!;
|
||||||
|
}
|
||||||
|
return preferredCueIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nearestIndex = matchingIndices[0]!;
|
||||||
|
let nearestDistance = Math.abs(nearestIndex - preferredCueIndex);
|
||||||
|
for (const matchIndex of matchingIndices) {
|
||||||
|
const distance = Math.abs(matchIndex - preferredCueIndex);
|
||||||
|
if (distance < nearestDistance) {
|
||||||
|
nearestIndex = matchIndex;
|
||||||
|
nearestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearestIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchingIndices[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubtitleSidebarModal(
|
||||||
|
ctx: RendererContext,
|
||||||
|
options: {
|
||||||
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
let snapshotPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let lastAppliedVideoMarginRatio: number | null = null;
|
||||||
|
|
||||||
|
function setStatus(message: string): void {
|
||||||
|
ctx.dom.subtitleSidebarStatus.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReservedSidebarWidthPx(): number {
|
||||||
|
const config = ctx.state.subtitleSidebarConfig;
|
||||||
|
if (!config || config.layout !== 'embedded' || !ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const measuredWidth = ctx.dom.subtitleSidebarContent.getBoundingClientRect().width;
|
||||||
|
if (Number.isFinite(measuredWidth) && measuredWidth > 0) {
|
||||||
|
return measuredWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(EMBEDDED_SIDEBAR_MIN_WIDTH_PX, config.maxWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncEmbeddedSidebarLayout(): void {
|
||||||
|
const config = ctx.state.subtitleSidebarConfig;
|
||||||
|
const reservedWidthPx = getReservedSidebarWidthPx();
|
||||||
|
const embedded = Boolean(config && config.layout === 'embedded' && reservedWidthPx > 0);
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
ctx.dom.subtitleSidebarContent.classList.add('subtitle-sidebar-content-embedded');
|
||||||
|
ctx.dom.subtitleSidebarModal.classList.add('subtitle-sidebar-modal-embedded');
|
||||||
|
document.body.classList.add('subtitle-sidebar-embedded-open');
|
||||||
|
} else {
|
||||||
|
ctx.dom.subtitleSidebarContent.classList.remove('subtitle-sidebar-content-embedded');
|
||||||
|
ctx.dom.subtitleSidebarModal.classList.remove('subtitle-sidebar-modal-embedded');
|
||||||
|
document.body.classList.remove('subtitle-sidebar-embedded-open');
|
||||||
|
}
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
'--subtitle-sidebar-reserved-width',
|
||||||
|
`${Math.max(0, Math.round(reservedWidthPx))}px`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const ratio =
|
||||||
|
embedded && Number.isFinite(viewportWidth) && viewportWidth > 0
|
||||||
|
? Math.min(EMBEDDED_SIDEBAR_MAX_RATIO, reservedWidthPx / viewportWidth)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastAppliedVideoMarginRatio !== null &&
|
||||||
|
Math.abs(ratio - lastAppliedVideoMarginRatio) < 0.0001
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastAppliedVideoMarginRatio = ratio;
|
||||||
|
window.electronAPI.sendMpvCommand([
|
||||||
|
'set_property',
|
||||||
|
'video-margin-ratio-right',
|
||||||
|
Number(ratio.toFixed(4)),
|
||||||
|
]);
|
||||||
|
if (ratio === 0) {
|
||||||
|
window.electronAPI.sendMpvCommand(['set_property', 'video-pan-x', 0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyConfig(snapshot: SubtitleSidebarSnapshot): void {
|
||||||
|
ctx.state.subtitleSidebarConfig = snapshot.config;
|
||||||
|
ctx.state.subtitleSidebarToggleKey = snapshot.config.toggleKey;
|
||||||
|
ctx.state.subtitleSidebarPauseVideoOnHover = snapshot.config.pauseVideoOnHover;
|
||||||
|
ctx.state.subtitleSidebarAutoScroll = snapshot.config.autoScroll;
|
||||||
|
const style = ctx.dom.subtitleSidebarModal.style;
|
||||||
|
style.setProperty('--subtitle-sidebar-max-width', `${snapshot.config.maxWidth}px`);
|
||||||
|
style.setProperty('--subtitle-sidebar-opacity', String(snapshot.config.opacity));
|
||||||
|
style.setProperty('--subtitle-sidebar-background-color', snapshot.config.backgroundColor);
|
||||||
|
style.setProperty('--subtitle-sidebar-text-color', snapshot.config.textColor);
|
||||||
|
style.setProperty('--subtitle-sidebar-font-family', snapshot.config.fontFamily);
|
||||||
|
style.setProperty('--subtitle-sidebar-font-size', `${snapshot.config.fontSize}px`);
|
||||||
|
style.setProperty('--subtitle-sidebar-timestamp-color', snapshot.config.timestampColor);
|
||||||
|
style.setProperty('--subtitle-sidebar-active-line-color', snapshot.config.activeLineColor);
|
||||||
|
style.setProperty(
|
||||||
|
'--subtitle-sidebar-active-background-color',
|
||||||
|
snapshot.config.activeLineBackgroundColor,
|
||||||
|
);
|
||||||
|
style.setProperty(
|
||||||
|
'--subtitle-sidebar-hover-background-color',
|
||||||
|
snapshot.config.hoverLineBackgroundColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekToCue(cue: SubtitleCue): void {
|
||||||
|
const targetTime = Math.min(cue.endTime - 0.01, cue.startTime + CLICK_SEEK_OFFSET_SEC);
|
||||||
|
window.electronAPI.sendMpvCommand([
|
||||||
|
'seek',
|
||||||
|
Math.max(cue.startTime, targetTime),
|
||||||
|
'absolute+exact',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeAutoScrollActiveCue(
|
||||||
|
previousActiveCueIndex: number,
|
||||||
|
behavior: ScrollBehavior = 'smooth',
|
||||||
|
force = false,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
!ctx.state.subtitleSidebarAutoScroll ||
|
||||||
|
ctx.state.subtitleSidebarActiveCueIndex < 0 ||
|
||||||
|
(!force && ctx.state.subtitleSidebarActiveCueIndex === previousActiveCueIndex) ||
|
||||||
|
Date.now() < ctx.state.subtitleSidebarManualScrollUntilMs
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = ctx.dom.subtitleSidebarList;
|
||||||
|
const active = list.children[ctx.state.subtitleSidebarActiveCueIndex] as HTMLElement | undefined;
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetScrollTop =
|
||||||
|
active.offsetTop - (list.clientHeight - active.clientHeight) / 2;
|
||||||
|
list.scrollTo({
|
||||||
|
top: Math.max(0, targetScrollTop),
|
||||||
|
behavior,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCueList(): void {
|
||||||
|
ctx.dom.subtitleSidebarList.innerHTML = '';
|
||||||
|
for (const [index, cue] of ctx.state.subtitleSidebarCues.entries()) {
|
||||||
|
const row = document.createElement('li');
|
||||||
|
row.className = 'subtitle-sidebar-item';
|
||||||
|
row.classList.toggle('active', index === ctx.state.subtitleSidebarActiveCueIndex);
|
||||||
|
row.dataset.index = String(index);
|
||||||
|
|
||||||
|
const timestamp = document.createElement('div');
|
||||||
|
timestamp.className = 'subtitle-sidebar-timestamp';
|
||||||
|
timestamp.textContent = formatCueTimestamp(cue.startTime);
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'subtitle-sidebar-text';
|
||||||
|
text.textContent = cue.text;
|
||||||
|
|
||||||
|
row.appendChild(timestamp);
|
||||||
|
row.appendChild(text);
|
||||||
|
|
||||||
|
ctx.dom.subtitleSidebarList.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncActiveCueClasses(previousActiveCueIndex: number): void {
|
||||||
|
if (previousActiveCueIndex >= 0) {
|
||||||
|
const previous = ctx.dom.subtitleSidebarList.children[previousActiveCueIndex] as
|
||||||
|
| HTMLElement
|
||||||
|
| undefined;
|
||||||
|
previous?.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.state.subtitleSidebarActiveCueIndex >= 0) {
|
||||||
|
const current = ctx.dom.subtitleSidebarList.children[ctx.state.subtitleSidebarActiveCueIndex] as
|
||||||
|
| HTMLElement
|
||||||
|
| undefined;
|
||||||
|
current?.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActiveCue(
|
||||||
|
current: { text: string; startTime?: number | null } | null,
|
||||||
|
currentTimeSec: number | null = null,
|
||||||
|
): void {
|
||||||
|
const previousActiveCueIndex = ctx.state.subtitleSidebarActiveCueIndex;
|
||||||
|
ctx.state.subtitleSidebarActiveCueIndex = findActiveSubtitleCueIndex(
|
||||||
|
ctx.state.subtitleSidebarCues,
|
||||||
|
current,
|
||||||
|
currentTimeSec,
|
||||||
|
previousActiveCueIndex,
|
||||||
|
);
|
||||||
|
if (ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
syncActiveCueClasses(previousActiveCueIndex);
|
||||||
|
maybeAutoScrollActiveCue(previousActiveCueIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSnapshot(): Promise<SubtitleSidebarSnapshot> {
|
||||||
|
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
|
||||||
|
applyConfig(snapshot);
|
||||||
|
const cuesChanged = !subtitleCueListsEqual(ctx.state.subtitleSidebarCues, snapshot.cues);
|
||||||
|
if (cuesChanged) {
|
||||||
|
ctx.state.subtitleSidebarCues = snapshot.cues;
|
||||||
|
if (ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
renderCueList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateActiveCue(snapshot.currentSubtitle, snapshot.currentTimeSec ?? null);
|
||||||
|
syncEmbeddedSidebarLayout();
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSnapshotPolling(): void {
|
||||||
|
if (snapshotPollInterval) {
|
||||||
|
clearInterval(snapshotPollInterval);
|
||||||
|
}
|
||||||
|
snapshotPollInterval = setInterval(() => {
|
||||||
|
void refreshSnapshot();
|
||||||
|
}, SNAPSHOT_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSnapshotPolling(): void {
|
||||||
|
if (!snapshotPollInterval) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearInterval(snapshotPollInterval);
|
||||||
|
snapshotPollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSubtitleSidebarModal(): Promise<void> {
|
||||||
|
const snapshot = await refreshSnapshot();
|
||||||
|
ctx.dom.subtitleSidebarList.innerHTML = '';
|
||||||
|
if (!snapshot.config.enabled) {
|
||||||
|
setStatus('Subtitle sidebar disabled in config.');
|
||||||
|
} else if (snapshot.cues.length === 0) {
|
||||||
|
setStatus('No parsed subtitle cues available.');
|
||||||
|
} else {
|
||||||
|
setStatus(`${snapshot.cues.length} parsed subtitle lines`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.state.subtitleSidebarModalOpen = true;
|
||||||
|
ctx.dom.subtitleSidebarModal.classList.remove('hidden');
|
||||||
|
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'false');
|
||||||
|
renderCueList();
|
||||||
|
syncActiveCueClasses(-1);
|
||||||
|
maybeAutoScrollActiveCue(-1, 'auto', true);
|
||||||
|
startSnapshotPolling();
|
||||||
|
syncEmbeddedSidebarLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSubtitleSidebarModal(): void {
|
||||||
|
if (!ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.state.subtitleSidebarModalOpen = false;
|
||||||
|
ctx.dom.subtitleSidebarModal.classList.add('hidden');
|
||||||
|
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
|
||||||
|
stopSnapshotPolling();
|
||||||
|
syncEmbeddedSidebarLayout();
|
||||||
|
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||||
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSubtitleSidebarModal(): Promise<void> {
|
||||||
|
if (ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
closeSubtitleSidebarModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await openSubtitleSidebarModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubtitleUpdated(data: SubtitleData): void {
|
||||||
|
if (ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateActiveCue(
|
||||||
|
{ text: data.text, startTime: data.startTime },
|
||||||
|
data.startTime ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireDomEvents(): void {
|
||||||
|
ctx.dom.subtitleSidebarClose.addEventListener('click', () => {
|
||||||
|
closeSubtitleSidebarModal();
|
||||||
|
});
|
||||||
|
ctx.dom.subtitleSidebarList.addEventListener('click', (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Element)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = target.closest<HTMLElement>('.subtitle-sidebar-item');
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = Number.parseInt(row.dataset.index ?? '', 10);
|
||||||
|
if (!Number.isInteger(index) || index < 0 || index >= ctx.state.subtitleSidebarCues.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cue = ctx.state.subtitleSidebarCues[index];
|
||||||
|
if (!cue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seekToCue(cue);
|
||||||
|
});
|
||||||
|
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {
|
||||||
|
ctx.state.subtitleSidebarManualScrollUntilMs = Date.now() + MANUAL_SCROLL_HOLD_MS;
|
||||||
|
});
|
||||||
|
ctx.dom.subtitleSidebarModal.addEventListener('mouseenter', async () => {
|
||||||
|
if (!ctx.state.subtitleSidebarPauseVideoOnHover || ctx.state.subtitleSidebarPausedByHover) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const paused = await window.electronAPI.getPlaybackPaused();
|
||||||
|
if (paused === false) {
|
||||||
|
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||||
|
ctx.state.subtitleSidebarPausedByHover = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ctx.dom.subtitleSidebarModal.addEventListener('mouseleave', () => {
|
||||||
|
if (!ctx.state.subtitleSidebarPausedByHover) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.state.subtitleSidebarPausedByHover = false;
|
||||||
|
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'no']);
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (!ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncEmbeddedSidebarLayout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openSubtitleSidebarModal,
|
||||||
|
closeSubtitleSidebarModal,
|
||||||
|
toggleSubtitleSidebarModal,
|
||||||
|
refreshSubtitleSidebarSnapshot: refreshSnapshot,
|
||||||
|
wireDomEvents,
|
||||||
|
handleSubtitleUpdated,
|
||||||
|
seekToCue,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import { createControllerSelectModal } from './modals/controller-select.js';
|
|||||||
import { createJimakuModal } from './modals/jimaku.js';
|
import { createJimakuModal } from './modals/jimaku.js';
|
||||||
import { createKikuModal } from './modals/kiku.js';
|
import { createKikuModal } from './modals/kiku.js';
|
||||||
import { createSessionHelpModal } from './modals/session-help.js';
|
import { createSessionHelpModal } from './modals/session-help.js';
|
||||||
|
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
||||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||||
import { createSubsyncModal } from './modals/subsync.js';
|
import { createSubsyncModal } from './modals/subsync.js';
|
||||||
import { createPositioningController } from './positioning.js';
|
import { createPositioningController } from './positioning.js';
|
||||||
@@ -78,7 +79,8 @@ function isAnyModalOpen(): boolean {
|
|||||||
ctx.state.kikuModalOpen ||
|
ctx.state.kikuModalOpen ||
|
||||||
ctx.state.runtimeOptionsModalOpen ||
|
ctx.state.runtimeOptionsModalOpen ||
|
||||||
ctx.state.subsyncModalOpen ||
|
ctx.state.subsyncModalOpen ||
|
||||||
ctx.state.sessionHelpModalOpen
|
ctx.state.sessionHelpModalOpen ||
|
||||||
|
ctx.state.subtitleSidebarModalOpen
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +116,9 @@ const sessionHelpModal = createSessionHelpModal(ctx, {
|
|||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
});
|
});
|
||||||
|
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
|
||||||
|
modalStateReader: { isAnyModalOpen },
|
||||||
|
});
|
||||||
const kikuModal = createKikuModal(ctx, {
|
const kikuModal = createKikuModal(ctx, {
|
||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
@@ -143,6 +148,9 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|||||||
controllerDebugModal.openControllerDebugModal();
|
controllerDebugModal.openControllerDebugModal();
|
||||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
||||||
},
|
},
|
||||||
|
toggleSubtitleSidebarModal: () => {
|
||||||
|
void subtitleSidebarModal.toggleSubtitleSidebarModal();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const mouseHandlers = createMouseHandlers(ctx, {
|
const mouseHandlers = createMouseHandlers(ctx, {
|
||||||
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
||||||
@@ -183,6 +191,7 @@ function getSubtitleTextForPreview(data: SubtitleData | string): string {
|
|||||||
function getActiveModal(): string | null {
|
function getActiveModal(): string | null {
|
||||||
if (ctx.state.controllerSelectModalOpen) return 'controller-select';
|
if (ctx.state.controllerSelectModalOpen) return 'controller-select';
|
||||||
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
|
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
|
||||||
|
if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar';
|
||||||
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
||||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||||
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
||||||
@@ -198,6 +207,9 @@ function dismissActiveUiAfterError(): void {
|
|||||||
if (ctx.state.controllerDebugModalOpen) {
|
if (ctx.state.controllerDebugModalOpen) {
|
||||||
controllerDebugModal.closeControllerDebugModal();
|
controllerDebugModal.closeControllerDebugModal();
|
||||||
}
|
}
|
||||||
|
if (ctx.state.subtitleSidebarModalOpen) {
|
||||||
|
subtitleSidebarModal.closeSubtitleSidebarModal();
|
||||||
|
}
|
||||||
if (ctx.state.jimakuModalOpen) {
|
if (ctx.state.jimakuModalOpen) {
|
||||||
jimakuModal.closeJimakuModal();
|
jimakuModal.closeJimakuModal();
|
||||||
}
|
}
|
||||||
@@ -468,6 +480,7 @@ async function init(): Promise<void> {
|
|||||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
||||||
keyboardHandlers.handleSubtitleContentUpdated();
|
keyboardHandlers.handleSubtitleContentUpdated();
|
||||||
subtitleRenderer.renderSubtitle(data);
|
subtitleRenderer.renderSubtitle(data);
|
||||||
|
subtitleSidebarModal.handleSubtitleUpdated(data);
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -528,6 +541,7 @@ async function init(): Promise<void> {
|
|||||||
controllerSelectModal.wireDomEvents();
|
controllerSelectModal.wireDomEvents();
|
||||||
controllerDebugModal.wireDomEvents();
|
controllerDebugModal.wireDomEvents();
|
||||||
sessionHelpModal.wireDomEvents();
|
sessionHelpModal.wireDomEvents();
|
||||||
|
subtitleSidebarModal.wireDomEvents();
|
||||||
|
|
||||||
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
||||||
runGuarded('runtime-options:changed', () => {
|
runGuarded('runtime-options:changed', () => {
|
||||||
@@ -539,6 +553,11 @@ async function init(): Promise<void> {
|
|||||||
keyboardHandlers.updateKeybindings(payload.keybindings);
|
keyboardHandlers.updateKeybindings(payload.keybindings);
|
||||||
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
|
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
|
||||||
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
|
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
|
||||||
|
ctx.state.subtitleSidebarConfig = payload.subtitleSidebar;
|
||||||
|
ctx.state.subtitleSidebarToggleKey = payload.subtitleSidebar.toggleKey;
|
||||||
|
ctx.state.subtitleSidebarPauseVideoOnHover = payload.subtitleSidebar.pauseVideoOnHover;
|
||||||
|
ctx.state.subtitleSidebarAutoScroll = payload.subtitleSidebar.autoScroll;
|
||||||
|
void subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -555,6 +574,7 @@ async function init(): Promise<void> {
|
|||||||
|
|
||||||
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
||||||
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
||||||
|
await subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
|
||||||
|
|
||||||
positioning.applyStoredSubtitlePosition(
|
positioning.applyStoredSubtitlePosition(
|
||||||
await window.electronAPI.getSubtitlePosition(),
|
await window.electronAPI.getSubtitlePosition(),
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import type {
|
|||||||
RuntimeOptionState,
|
RuntimeOptionState,
|
||||||
RuntimeOptionValue,
|
RuntimeOptionValue,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
|
SubtitleCue,
|
||||||
|
SubtitleSidebarConfig,
|
||||||
SubsyncSourceTrack,
|
SubsyncSourceTrack,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ export type RendererState = {
|
|||||||
|
|
||||||
controllerSelectModalOpen: boolean;
|
controllerSelectModalOpen: boolean;
|
||||||
controllerDebugModalOpen: boolean;
|
controllerDebugModalOpen: boolean;
|
||||||
|
subtitleSidebarModalOpen: boolean;
|
||||||
controllerDeviceSelectedIndex: number;
|
controllerDeviceSelectedIndex: number;
|
||||||
controllerConfig: ResolvedControllerConfig | null;
|
controllerConfig: ResolvedControllerConfig | null;
|
||||||
connectedGamepads: ControllerDeviceInfo[];
|
connectedGamepads: ControllerDeviceInfo[];
|
||||||
@@ -67,6 +70,14 @@ export type RendererState = {
|
|||||||
|
|
||||||
sessionHelpModalOpen: boolean;
|
sessionHelpModalOpen: boolean;
|
||||||
sessionHelpSelectedIndex: number;
|
sessionHelpSelectedIndex: number;
|
||||||
|
subtitleSidebarCues: SubtitleCue[];
|
||||||
|
subtitleSidebarActiveCueIndex: number;
|
||||||
|
subtitleSidebarToggleKey: string;
|
||||||
|
subtitleSidebarPauseVideoOnHover: boolean;
|
||||||
|
subtitleSidebarAutoScroll: boolean;
|
||||||
|
subtitleSidebarConfig: Required<SubtitleSidebarConfig> | null;
|
||||||
|
subtitleSidebarManualScrollUntilMs: number;
|
||||||
|
subtitleSidebarPausedByHover: boolean;
|
||||||
|
|
||||||
knownWordColor: string;
|
knownWordColor: string;
|
||||||
nPlusOneColor: string;
|
nPlusOneColor: string;
|
||||||
@@ -139,6 +150,7 @@ export function createRendererState(): RendererState {
|
|||||||
|
|
||||||
controllerSelectModalOpen: false,
|
controllerSelectModalOpen: false,
|
||||||
controllerDebugModalOpen: false,
|
controllerDebugModalOpen: false,
|
||||||
|
subtitleSidebarModalOpen: false,
|
||||||
controllerDeviceSelectedIndex: 0,
|
controllerDeviceSelectedIndex: 0,
|
||||||
controllerConfig: null,
|
controllerConfig: null,
|
||||||
connectedGamepads: [],
|
connectedGamepads: [],
|
||||||
@@ -148,6 +160,14 @@ export function createRendererState(): RendererState {
|
|||||||
|
|
||||||
sessionHelpModalOpen: false,
|
sessionHelpModalOpen: false,
|
||||||
sessionHelpSelectedIndex: 0,
|
sessionHelpSelectedIndex: 0,
|
||||||
|
subtitleSidebarCues: [],
|
||||||
|
subtitleSidebarActiveCueIndex: -1,
|
||||||
|
subtitleSidebarToggleKey: 'Backslash',
|
||||||
|
subtitleSidebarPauseVideoOnHover: false,
|
||||||
|
subtitleSidebarAutoScroll: true,
|
||||||
|
subtitleSidebarConfig: null,
|
||||||
|
subtitleSidebarManualScrollUntilMs: 0,
|
||||||
|
subtitleSidebarPausedByHover: false,
|
||||||
|
|
||||||
knownWordColor: '#a6da95',
|
knownWordColor: '#a6da95',
|
||||||
nPlusOneColor: '#c6a0f6',
|
nPlusOneColor: '#c6a0f6',
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ body {
|
|||||||
'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Arial Unicode MS', Arial, sans-serif;
|
'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Arial Unicode MS', Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--subtitle-sidebar-reserved-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
#overlay {
|
#overlay {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -294,13 +298,19 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.subtitle-sidebar-embedded-open #subtitleContainer {
|
||||||
|
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||||
|
transform: translateX(calc(var(--subtitle-sidebar-reserved-width) * -0.5));
|
||||||
|
}
|
||||||
|
|
||||||
#subtitleContainer {
|
#subtitleContainer {
|
||||||
max-width: 80%;
|
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||||
margin-bottom: 60px;
|
margin-bottom: 60px;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
background: rgb(30, 32, 48, 0.88);
|
background: rgb(30, 32, 48, 0.88);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#subtitleRoot {
|
#subtitleRoot {
|
||||||
@@ -705,6 +715,11 @@ body.platform-macos.layer-visible #subtitleRoot {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.subtitle-sidebar-embedded-open #secondarySubContainer {
|
||||||
|
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||||
|
transform: translateX(calc(-50% - (var(--subtitle-sidebar-reserved-width) * 0.5)));
|
||||||
|
}
|
||||||
|
|
||||||
#secondarySubContainer {
|
#secondarySubContainer {
|
||||||
--secondary-sub-background-color: transparent;
|
--secondary-sub-background-color: transparent;
|
||||||
--secondary-sub-backdrop-filter: none;
|
--secondary-sub-backdrop-filter: none;
|
||||||
@@ -712,13 +727,14 @@ body.platform-macos.layer-visible #subtitleRoot {
|
|||||||
top: 40px;
|
top: 40px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
max-width: 80%;
|
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||||
padding: 10px 18px;
|
padding: 10px 18px;
|
||||||
background: var(--secondary-sub-background-color, transparent);
|
background: var(--secondary-sub-background-color, transparent);
|
||||||
backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
||||||
-webkit-backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
-webkit-backdrop-filter: var(--secondary-sub-backdrop-filter, none);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.layer-modal #subtitleContainer,
|
body.layer-modal #subtitleContainer,
|
||||||
@@ -763,6 +779,10 @@ body.settings-modal-open #secondarySubContainer {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover {
|
||||||
|
transform: translateX(calc(var(--subtitle-sidebar-reserved-width) * -0.5));
|
||||||
|
}
|
||||||
|
|
||||||
#secondarySubContainer.secondary-sub-hover {
|
#secondarySubContainer.secondary-sub-hover {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
@@ -1362,6 +1382,201 @@ iframe[id^='yomitan-popup'] {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-modal {
|
||||||
|
inset: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 14px;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.subtitle-sidebar-embedded-open .subtitle-sidebar-modal {
|
||||||
|
padding: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-content {
|
||||||
|
width: min(var(--subtitle-sidebar-max-width, 420px), 92vw);
|
||||||
|
max-height: calc(100vh - 28px);
|
||||||
|
height: auto;
|
||||||
|
margin-left: auto;
|
||||||
|
font-family:
|
||||||
|
var(
|
||||||
|
--subtitle-sidebar-font-family,
|
||||||
|
'M PLUS 1',
|
||||||
|
'Noto Sans CJK JP',
|
||||||
|
'Hiragino Sans',
|
||||||
|
sans-serif
|
||||||
|
);
|
||||||
|
font-size: var(--subtitle-sidebar-font-size, 16px);
|
||||||
|
background: var(--subtitle-sidebar-background-color, rgba(73, 77, 100, 0.9));
|
||||||
|
color: var(--subtitle-sidebar-text-color, #cad3f5);
|
||||||
|
border: 1px solid rgba(110, 115, 141, 0.18);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.2),
|
||||||
|
inset 0 1px 0 rgba(183, 189, 248, 0.06);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.4);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||||
|
opacity: var(--subtitle-sidebar-opacity, 0.95);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-content .modal-header {
|
||||||
|
padding: 10px 14px 8px;
|
||||||
|
border-bottom: 1px solid rgba(110, 115, 141, 0.14);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-content .modal-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #b8c0e0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-content .modal-close {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(73, 77, 100, 0.5);
|
||||||
|
border: 1px solid rgba(110, 115, 141, 0.2);
|
||||||
|
color: #a5adcb;
|
||||||
|
transition: all 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-content .modal-close:hover {
|
||||||
|
background: rgba(91, 96, 120, 0.6);
|
||||||
|
color: #cad3f5;
|
||||||
|
border-color: rgba(110, 115, 141, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
|
||||||
|
width: min(var(--subtitle-sidebar-max-width, 420px), 44vw);
|
||||||
|
max-height: 100vh;
|
||||||
|
height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
border-top: none;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: none;
|
||||||
|
box-shadow:
|
||||||
|
-12px 0 32px rgba(0, 0, 0, 0.3),
|
||||||
|
-1px 0 0 rgba(110, 115, 141, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-body {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-content .runtime-options-status {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 14px;
|
||||||
|
color: #6e738d;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-list {
|
||||||
|
position: relative;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-list::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(110, 115, 141, 0.25);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(110, 115, 141, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-bottom: 1px solid rgba(110, 115, 141, 0.08);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 120ms ease,
|
||||||
|
color 120ms ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item:hover {
|
||||||
|
background: var(--subtitle-sidebar-hover-background-color, rgba(54, 58, 79, 0.65));
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item.active {
|
||||||
|
background: var(--subtitle-sidebar-active-background-color, rgba(138, 173, 244, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
background: var(--subtitle-sidebar-active-line-color, #f5bde6);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-timestamp {
|
||||||
|
font-size: calc(var(--subtitle-sidebar-font-size, 16px) * 0.72);
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--subtitle-sidebar-timestamp-color, #6e738d);
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item:hover .subtitle-sidebar-timestamp {
|
||||||
|
color: var(--subtitle-sidebar-timestamp-color, #a5adcb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item.active .subtitle-sidebar-timestamp {
|
||||||
|
color: var(--subtitle-sidebar-active-line-color, #f5bde6);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-item.active .subtitle-sidebar-text {
|
||||||
|
color: var(--subtitle-sidebar-active-line-color, #f5bde6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-sidebar-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 1em;
|
||||||
|
color: var(--subtitle-sidebar-text-color, #cad3f5);
|
||||||
|
}
|
||||||
|
|
||||||
.session-help-content {
|
.session-help-content {
|
||||||
width: min(760px, 92%);
|
width: min(760px, 92%);
|
||||||
max-height: 84%;
|
max-height: 84%;
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ export type RendererDom = {
|
|||||||
controllerDebugAxes: HTMLPreElement;
|
controllerDebugAxes: HTMLPreElement;
|
||||||
controllerDebugButtons: HTMLPreElement;
|
controllerDebugButtons: HTMLPreElement;
|
||||||
controllerDebugButtonIndices: HTMLPreElement;
|
controllerDebugButtonIndices: HTMLPreElement;
|
||||||
|
subtitleSidebarModal: HTMLDivElement;
|
||||||
|
subtitleSidebarContent: HTMLDivElement;
|
||||||
|
subtitleSidebarClose: HTMLButtonElement;
|
||||||
|
subtitleSidebarStatus: HTMLDivElement;
|
||||||
|
subtitleSidebarList: HTMLUListElement;
|
||||||
|
|
||||||
sessionHelpModal: HTMLDivElement;
|
sessionHelpModal: HTMLDivElement;
|
||||||
sessionHelpClose: HTMLButtonElement;
|
sessionHelpClose: HTMLButtonElement;
|
||||||
@@ -171,6 +176,11 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>(
|
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>(
|
||||||
'controllerDebugButtonIndices',
|
'controllerDebugButtonIndices',
|
||||||
),
|
),
|
||||||
|
subtitleSidebarModal: getRequiredElement<HTMLDivElement>('subtitleSidebarModal'),
|
||||||
|
subtitleSidebarContent: getRequiredElement<HTMLDivElement>('subtitleSidebarContent'),
|
||||||
|
subtitleSidebarClose: getRequiredElement<HTMLButtonElement>('subtitleSidebarClose'),
|
||||||
|
subtitleSidebarStatus: getRequiredElement<HTMLDivElement>('subtitleSidebarStatus'),
|
||||||
|
subtitleSidebarList: getRequiredElement<HTMLUListElement>('subtitleSidebarList'),
|
||||||
|
|
||||||
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
||||||
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const OVERLAY_HOSTED_MODALS = [
|
|||||||
'kiku',
|
'kiku',
|
||||||
'controller-select',
|
'controller-select',
|
||||||
'controller-debug',
|
'controller-debug',
|
||||||
|
'subtitle-sidebar',
|
||||||
] as const;
|
] as const;
|
||||||
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ export const IPC_CHANNELS = {
|
|||||||
getCurrentSubtitle: 'get-current-subtitle',
|
getCurrentSubtitle: 'get-current-subtitle',
|
||||||
getCurrentSubtitleRaw: 'get-current-subtitle-raw',
|
getCurrentSubtitleRaw: 'get-current-subtitle-raw',
|
||||||
getCurrentSubtitleAss: 'get-current-subtitle-ass',
|
getCurrentSubtitleAss: 'get-current-subtitle-ass',
|
||||||
|
getSubtitleSidebarSnapshot: 'get-subtitle-sidebar-snapshot',
|
||||||
getPlaybackPaused: 'get-playback-paused',
|
getPlaybackPaused: 'get-playback-paused',
|
||||||
getSubtitlePosition: 'get-subtitle-position',
|
getSubtitlePosition: 'get-subtitle-position',
|
||||||
getSubtitleStyle: 'get-subtitle-style',
|
getSubtitleStyle: 'get-subtitle-style',
|
||||||
|
|||||||
49
src/types.ts
49
src/types.ts
@@ -364,6 +364,32 @@ export interface ResolvedTokenPos2ExclusionConfig {
|
|||||||
|
|
||||||
export type FrequencyDictionaryMode = 'single' | 'banded';
|
export type FrequencyDictionaryMode = 'single' | 'banded';
|
||||||
|
|
||||||
|
export interface SubtitleCue {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubtitleSidebarLayout = 'overlay' | 'embedded';
|
||||||
|
|
||||||
|
export interface SubtitleSidebarConfig {
|
||||||
|
enabled?: boolean;
|
||||||
|
layout?: SubtitleSidebarLayout;
|
||||||
|
toggleKey?: string;
|
||||||
|
pauseVideoOnHover?: boolean;
|
||||||
|
autoScroll?: boolean;
|
||||||
|
maxWidth?: number;
|
||||||
|
opacity?: number;
|
||||||
|
backgroundColor?: string;
|
||||||
|
textColor?: string;
|
||||||
|
fontFamily?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
timestampColor?: string;
|
||||||
|
activeLineColor?: string;
|
||||||
|
activeLineBackgroundColor?: string;
|
||||||
|
hoverLineBackgroundColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShortcutsConfig {
|
export interface ShortcutsConfig {
|
||||||
toggleVisibleOverlayGlobal?: string | null;
|
toggleVisibleOverlayGlobal?: string | null;
|
||||||
copySubtitle?: string | null;
|
copySubtitle?: string | null;
|
||||||
@@ -675,6 +701,7 @@ export interface Config {
|
|||||||
subsync?: SubsyncConfig;
|
subsync?: SubsyncConfig;
|
||||||
startupWarmups?: StartupWarmupsConfig;
|
startupWarmups?: StartupWarmupsConfig;
|
||||||
subtitleStyle?: SubtitleStyleConfig;
|
subtitleStyle?: SubtitleStyleConfig;
|
||||||
|
subtitleSidebar?: SubtitleSidebarConfig;
|
||||||
auto_start_overlay?: boolean;
|
auto_start_overlay?: boolean;
|
||||||
jimaku?: JimakuConfig;
|
jimaku?: JimakuConfig;
|
||||||
anilist?: AnilistConfig;
|
anilist?: AnilistConfig;
|
||||||
@@ -807,6 +834,7 @@ export interface ResolvedConfig {
|
|||||||
bandedColors: [string, string, string, string, string];
|
bandedColors: [string, string, string, string, string];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
subtitleSidebar: Required<SubtitleSidebarConfig>;
|
||||||
auto_start_overlay: boolean;
|
auto_start_overlay: boolean;
|
||||||
jimaku: JimakuConfig & {
|
jimaku: JimakuConfig & {
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
@@ -939,6 +967,19 @@ export interface ClipboardAppendResult {
|
|||||||
export interface SubtitleData {
|
export interface SubtitleData {
|
||||||
text: string;
|
text: string;
|
||||||
tokens: MergedToken[] | null;
|
tokens: MergedToken[] | null;
|
||||||
|
startTime?: number | null;
|
||||||
|
endTime?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleSidebarSnapshot {
|
||||||
|
cues: SubtitleCue[];
|
||||||
|
currentTimeSec?: number | null;
|
||||||
|
currentSubtitle: {
|
||||||
|
text: string;
|
||||||
|
startTime: number | null;
|
||||||
|
endTime: number | null;
|
||||||
|
};
|
||||||
|
config: Required<SubtitleSidebarConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MpvSubtitleRenderMetrics {
|
export interface MpvSubtitleRenderMetrics {
|
||||||
@@ -1057,6 +1098,7 @@ export type JimakuDownloadResult =
|
|||||||
export interface ConfigHotReloadPayload {
|
export interface ConfigHotReloadPayload {
|
||||||
keybindings: Keybinding[];
|
keybindings: Keybinding[];
|
||||||
subtitleStyle: SubtitleStyleConfig | null;
|
subtitleStyle: SubtitleStyleConfig | null;
|
||||||
|
subtitleSidebar: Required<SubtitleSidebarConfig>;
|
||||||
secondarySubMode: SecondarySubMode;
|
secondarySubMode: SecondarySubMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1075,6 +1117,7 @@ export interface ElectronAPI {
|
|||||||
getCurrentSubtitle: () => Promise<SubtitleData>;
|
getCurrentSubtitle: () => Promise<SubtitleData>;
|
||||||
getCurrentSubtitleRaw: () => Promise<string>;
|
getCurrentSubtitleRaw: () => Promise<string>;
|
||||||
getCurrentSubtitleAss: () => Promise<string>;
|
getCurrentSubtitleAss: () => Promise<string>;
|
||||||
|
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
|
||||||
getPlaybackPaused: () => Promise<boolean | null>;
|
getPlaybackPaused: () => Promise<boolean | null>;
|
||||||
onSubtitleAss: (callback: (assText: string) => void) => void;
|
onSubtitleAss: (callback: (assText: string) => void) => void;
|
||||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||||
@@ -1134,7 +1177,8 @@ export interface ElectronAPI {
|
|||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
| 'kiku'
|
| 'kiku'
|
||||||
| 'controller-select'
|
| 'controller-select'
|
||||||
| 'controller-debug',
|
| 'controller-debug'
|
||||||
|
| 'subtitle-sidebar',
|
||||||
) => void;
|
) => void;
|
||||||
notifyOverlayModalOpened: (
|
notifyOverlayModalOpened: (
|
||||||
modal:
|
modal:
|
||||||
@@ -1143,7 +1187,8 @@ export interface ElectronAPI {
|
|||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
| 'kiku'
|
| 'kiku'
|
||||||
| 'controller-select'
|
| 'controller-select'
|
||||||
| 'controller-debug',
|
| 'controller-debug'
|
||||||
|
| 'subtitle-sidebar',
|
||||||
) => void;
|
) => void;
|
||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user