From ea86f4e504c5edce3d3f62a1b14ac62f0223340b Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 20 Mar 2026 23:00:26 -0700 Subject: [PATCH] feat(subtitle-sidebar): add sidebar runtime and modal plumbing --- src/core/services/ipc.test.ts | 49 ++ src/core/services/ipc.ts | 11 + src/core/services/subtitle-cue-parser.ts | 8 +- src/main.ts | 327 ++++++++- src/main/dependencies.ts | 2 + .../runtime/config-hot-reload-handlers.ts | 1 + .../runtime/mpv-main-event-actions.test.ts | 33 +- src/main/runtime/mpv-main-event-actions.ts | 2 + .../runtime/mpv-main-event-bindings.test.ts | 3 + src/main/runtime/mpv-main-event-bindings.ts | 10 +- .../runtime/mpv-main-event-main-deps.test.ts | 5 +- src/main/runtime/mpv-main-event-main-deps.ts | 2 + .../runtime/subtitle-prefetch-init.test.ts | 87 +++ src/main/runtime/subtitle-prefetch-init.ts | 21 +- .../runtime/subtitle-prefetch-source.test.ts | 27 + src/main/runtime/subtitle-prefetch-source.ts | 31 +- src/main/state.ts | 5 + src/preload.ts | 2 + src/renderer/handlers/keyboard.ts | 27 + src/renderer/index.html | 12 + src/renderer/modals/subtitle-sidebar.test.ts | 622 ++++++++++++++++++ src/renderer/modals/subtitle-sidebar.ts | 461 +++++++++++++ src/renderer/renderer.ts | 22 +- src/renderer/state.ts | 20 + src/renderer/style.css | 219 +++++- src/renderer/utils/dom.ts | 10 + src/shared/ipc/contracts.ts | 2 + src/types.ts | 49 +- 28 files changed, 2013 insertions(+), 57 deletions(-) create mode 100644 src/renderer/modals/subtitle-sidebar.test.ts create mode 100644 src/renderer/modals/subtitle-sidebar.ts diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index b92473d..65e5da5 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import type { SubtitleSidebarSnapshot } from '../../types'; interface FakeIpcRegistrar { on: Map 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 { return { onOverlayModalClosed: () => {}, @@ -88,6 +113,7 @@ function createRegisterIpcDeps(overrides: Partial = {}): IpcServ tokenizeCurrentSubtitle: async () => null, getCurrentSubtitleRaw: () => '', getCurrentSubtitleAss: () => '', + getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(), getPlaybackPaused: () => false, getSubtitlePosition: () => null, getSubtitleStyle: () => null, @@ -173,6 +199,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { tokenizeCurrentSubtitle: async () => null, getCurrentSubtitleRaw: () => '', getCurrentSubtitleAss: () => '', + getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(), getPlaybackPaused: () => true, getSubtitlePosition: () => null, getSubtitleStyle: () => null, @@ -269,6 +296,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = cycles.push({ id, direction }); return { ok: true }; }, + getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(), reportOverlayContentBounds: () => {}, getAnilistStatus: () => ({}), 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', () => { const { registrar, handlers } = createFakeIpcRegistrar(); const calls: string[] = []; @@ -530,6 +576,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { tokenizeCurrentSubtitle: async () => null, getCurrentSubtitleRaw: () => '', getCurrentSubtitleAss: () => '', + getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(), getPlaybackPaused: () => false, getSubtitlePosition: () => null, getSubtitleStyle: () => null, @@ -596,6 +643,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon tokenizeCurrentSubtitle: async () => null, getCurrentSubtitleRaw: () => '', getCurrentSubtitleAss: () => '', + getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(), getPlaybackPaused: () => false, getSubtitlePosition: () => null, getSubtitleStyle: () => null, @@ -667,6 +715,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy tokenizeCurrentSubtitle: async () => null, getCurrentSubtitleRaw: () => '', getCurrentSubtitleAss: () => '', + getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(), getPlaybackPaused: () => false, getSubtitlePosition: () => null, getSubtitleStyle: () => null, diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 8ca671e..a8a4612 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -6,6 +6,7 @@ import type { ResolvedControllerConfig, RuntimeOptionId, RuntimeOptionValue, + SubtitleSidebarSnapshot, SubtitlePosition, SubsyncManualRunRequest, SubsyncResult, @@ -37,6 +38,7 @@ export interface IpcServiceDeps { tokenizeCurrentSubtitle: () => Promise; getCurrentSubtitleRaw: () => string; getCurrentSubtitleAss: () => string; + getSubtitleSidebarSnapshot?: () => Promise; getPlaybackPaused: () => boolean | null; getSubtitlePosition: () => unknown; getSubtitleStyle: () => unknown; @@ -143,6 +145,7 @@ export interface IpcDepsRuntimeOptions { tokenizeCurrentSubtitle: () => Promise; getCurrentSubtitleRaw: () => string; getCurrentSubtitleAss: () => string; + getSubtitleSidebarSnapshot?: () => Promise; getPlaybackPaused: () => boolean | null; getSubtitlePosition: () => unknown; getSubtitleStyle: () => unknown; @@ -190,6 +193,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle, getCurrentSubtitleRaw: options.getCurrentSubtitleRaw, getCurrentSubtitleAss: options.getCurrentSubtitleAss, + getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot, getPlaybackPaused: options.getPlaybackPaused, getSubtitlePosition: options.getSubtitlePosition, getSubtitleStyle: options.getSubtitleStyle, @@ -321,6 +325,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar 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, () => { return deps.getPlaybackPaused(); }); diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts index 6314cb6..760bba3 100644 --- a/src/core/services/subtitle-cue-parser.ts +++ b/src/core/services/subtitle-cue-parser.ts @@ -183,7 +183,13 @@ export function parseSubtitleCues(content: string, filename: string): SubtitleCu cues = parseAssCues(content); break; 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); diff --git a/src/main.ts b/src/main.ts index f2e9dff..2b3105d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -438,10 +438,11 @@ import { parseSubtitleCues } from './core/services/subtitle-cue-parser'; import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch'; import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch'; import { - getActiveExternalSubtitleSource, + buildSubtitleSidebarSourceKey, resolveSubtitleSourcePath, } from './main/runtime/subtitle-prefetch-source'; import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init'; +import { codecToExtension } from './subsync/utils'; if (process.platform === 'linux') { app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); @@ -1142,15 +1143,23 @@ function maybeSignalPluginAutoplayReady( let appTray: Tray | null = null; let tokenizeSubtitleDeferred: ((text: string) => Promise) | null = null; +function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData { + return { + ...payload, + startTime: appState.mpvClient?.currentSubStart ?? null, + endTime: appState.mpvClient?.currentSubEnd ?? null, + }; +} function emitSubtitlePayload(payload: SubtitleData): void { - appState.currentSubtitleData = payload; - broadcastToOverlayWindows('subtitle:set', payload); - subtitleWsService.broadcast(payload, { + const timedPayload = withCurrentSubtitleTiming(payload); + appState.currentSubtitleData = timedPayload; + broadcastToOverlayWindows('subtitle:set', timedPayload); + subtitleWsService.broadcast(timedPayload, { enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, }); - annotationSubtitleWsService.broadcast(payload, { + annotationSubtitleWsService.broadcast(timedPayload, { enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled, topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, @@ -1200,6 +1209,10 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ isCacheFull: () => subtitleProcessingController.isCacheFull(), logInfo: (message) => logger.info(message), logWarn: (message) => logger.warn(message), + onParsedSubtitleCuesChanged: (cues, sourceKey) => { + appState.activeParsedSubtitleCues = cues ?? []; + appState.activeParsedSubtitleSource = sourceKey; + }, }); async function refreshSubtitlePrefetchFromActiveTrack(): Promise { @@ -1209,19 +1222,40 @@ async function refreshSubtitlePrefetchFromActiveTrack(): Promise { } 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('sid'), - ]); - const externalFilename = getActiveExternalSubtitleSource(trackListRaw, sidRaw); - if (!externalFilename) { + client.requestProperty('path'), + ]); + const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : ''; + if (!videoPath) { subtitlePrefetchInitController.cancelPendingInit(); return; } - await subtitlePrefetchInitController.initSubtitlePrefetch( - externalFilename, - lastObservedTimePos, + + const resolvedSource = await resolveActiveSubtitleSidebarSource( + currentExternalFilenameRaw, + currentTrackRaw, + trackListRaw, + sidRaw, + videoPath, ); + if (!resolvedSource) { + subtitlePrefetchInitController.cancelPendingInit(); + return; + } + try { + await subtitlePrefetchInitController.initSubtitlePrefetch( + resolvedSource.path, + lastObservedTimePos, + resolvedSource.sourceKey, + ); + } finally { + await resolvedSource.cleanup?.(); + } } catch { // Track list query failed; skip subtitle prefetch refresh. } @@ -2965,6 +2999,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ ? { text: appState.currentSubText, tokens: null, + startTime: appState.mpvClient?.currentSubStart ?? null, + endTime: appState.mpvClient?.currentSubEnd ?? null, } : null), () => ({ @@ -2983,6 +3019,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ ? { text: appState.currentSubText, tokens: null, + startTime: appState.mpvClient?.currentSubStart ?? null, + endTime: appState.mpvClient?.currentSubEnd ?? null, } : null), () => ({ @@ -3257,6 +3295,9 @@ const { restoreMpvSubVisibility: () => { restoreOverlayMpvSubtitles(); }, + resetSubtitleSidebarEmbeddedLayout: () => { + resetSubtitleSidebarEmbeddedLayoutRuntime(); + }, getCurrentAnilistMediaKey: () => getCurrentAnilistMediaKey(), resetAnilistMediaTracking: (mediaKey) => { resetAnilistMediaTracking(mediaKey); @@ -3475,6 +3516,11 @@ function createMpvClientRuntimeService(): 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): void { updateMpvSubtitleRenderMetricsHandler(patch); } @@ -3919,6 +3965,168 @@ async function loadSubtitleSourceText(source: string): Promise { 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 } | 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((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 } | 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({ getMpvClient: () => appState.mpvClient, loadSubtitleSourceText, @@ -3976,9 +4184,102 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ openYomitanSettings: () => openYomitanSettings(), quitApp: () => requestAppQuit(), toggleVisibleOverlay: () => toggleVisibleOverlay(), - tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText), + tokenizeCurrentSubtitle: async () => withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)), getCurrentSubtitleRaw: () => appState.currentSubText, 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, getSubtitlePosition: () => loadSubtitlePosition(), getSubtitleStyle: () => { diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 1ae07d0..debb8b9 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -63,6 +63,7 @@ export interface MainIpcRuntimeServiceDepsParams { tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle']; getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw']; getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss']; + getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot']; getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused']; focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow']; getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition']; @@ -212,6 +213,7 @@ export function createMainIpcRuntimeServiceDeps( tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle, getCurrentSubtitleRaw: params.getCurrentSubtitleRaw, getCurrentSubtitleAss: params.getCurrentSubtitleAss, + getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot, getPlaybackPaused: params.getPlaybackPaused, getSubtitlePosition: params.getSubtitlePosition, getSubtitleStyle: params.getSubtitleStyle, diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index 602be3c..cf4f9c6 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -36,6 +36,7 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe return { keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS), subtitleStyle: resolveSubtitleStyleForRenderer(config), + subtitleSidebar: config.subtitleSidebar, secondarySubMode: config.secondarySub.defaultMode, }; } diff --git a/src/main/runtime/mpv-main-event-actions.test.ts b/src/main/runtime/mpv-main-event-actions.test.ts index eb0b4a7..dd32261 100644 --- a/src/main/runtime/mpv-main-event-actions.test.ts +++ b/src/main/runtime/mpv-main-event-actions.test.ts @@ -82,6 +82,7 @@ test('media path change handler reports stop for empty path and probes media key updateCurrentMediaPath: (path) => calls.push(`path:${path}`), reportJellyfinRemoteStopped: () => calls.push('stopped'), restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), + resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'), getCurrentAnilistMediaKey: () => 'show:1', resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(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, [ 'flush-playback', 'path:', + 'reset-sidebar-layout', 'stopped', 'restore-mpv-sub', '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}`), reportJellyfinRemoteStopped: () => calls.push('stopped'), restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), + resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'), getCurrentAnilistMediaKey: () => null, resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(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, [ 'path:/tmp/video.mkv', - 'reset:null', - '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-sidebar-layout', 'reset:null', 'sync', 'dict-sync', diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts index 77f9daa..2fd43ea 100644 --- a/src/main/runtime/mpv-main-event-actions.ts +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -46,6 +46,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: { updateCurrentMediaPath: (path: string) => void; reportJellyfinRemoteStopped: () => void; restoreMpvSubVisibility: () => void; + resetSubtitleSidebarEmbeddedLayout: () => void; getCurrentAnilistMediaKey: () => string | null; resetAnilistMediaTracking: (mediaKey: string | null) => void; maybeProbeAnilistDuration: (mediaKey: string) => void; @@ -62,6 +63,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: { deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath); } deps.updateCurrentMediaPath(normalizedPath); + deps.resetSubtitleSidebarEmbeddedLayout(); if (!normalizedPath) { deps.reportJellyfinRemoteStopped(); deps.restoreMpvSubVisibility(); diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index fd4c9f5..a3910e9 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -9,6 +9,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { const bind = createBindMpvMainEventHandlersHandler({ reportJellyfinRemoteStopped: () => calls.push('remote-stopped'), syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'), + resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'), hasInitialJellyfinPlayArg: () => false, isOverlayRuntimeInitialized: () => 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-track-change')?.({ sid: 3 }); 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 }); 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('subtitle-change:line')); assert.ok(calls.includes('subtitle-track-change')); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index 14266c6..7890818 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -21,6 +21,7 @@ type MpvEventClient = Parameters void; syncOverlayMpvSubtitleSuppression: () => void; + resetSubtitleSidebarEmbeddedLayout: () => void; scheduleCharacterDictionarySync?: () => void; hasInitialJellyfinPlayArg: () => boolean; isOverlayRuntimeInitialized: () => boolean; @@ -83,6 +84,12 @@ export function createBindMpvMainEventHandlersHandler(deps: { isMpvConnected: () => deps.isMpvConnected(), quitApp: () => deps.quitApp(), }); + const handleMpvConnectionChangeWithSidebarReset = ({ connected }: { connected: boolean }): void => { + if (connected) { + deps.resetSubtitleSidebarEmbeddedLayout(); + } + handleMpvConnectionChange({ connected }); + }; const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({ recordImmersionSubtitleLine: (text, start, end) => deps.recordImmersionSubtitleLine(text, start, end), @@ -110,6 +117,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { updateCurrentMediaPath: (path) => deps.updateCurrentMediaPath(path), reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(), + resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout(), getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(), resetAnilistMediaTracking: (mediaKey) => deps.resetAnilistMediaTracking(mediaKey), maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey), @@ -150,7 +158,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { }); createBindMpvClientEventHandlers({ - onConnectionChange: handleMpvConnectionChange, + onConnectionChange: handleMpvConnectionChangeWithSidebarReset, onSubtitleChange: handleMpvSubtitleChange, onSubtitleAssChange: handleMpvSubtitleAssChange, onSecondarySubtitleChange: handleMpvSecondarySubtitleChange, diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 5b8b77d..705d398 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -47,6 +47,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'), updateCurrentMediaPath: (path) => calls.push(`path:${path}`), restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), + resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'), getCurrentAnilistMediaKey: () => 'media-key', resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${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.updateCurrentMediaPath('/tmp/video'); deps.restoreMpvSubVisibility(); + deps.resetSubtitleSidebarEmbeddedLayout(); assert.equal(deps.getCurrentAnilistMediaKey(), 'media-key'); deps.resetAnilistMediaTracking('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('presence-refresh')); assert.ok(calls.includes('restore-mpv-sub')); - assert.ok(calls.includes('immersion-time:12.25')); - assert.ok(calls.includes('immersion-time:18.75')); + assert.ok(calls.includes('reset-sidebar-layout')); }); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 5d4ac65..2523861 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -50,6 +50,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { onSubtitleTrackListChange?: (trackList: unknown[] | null) => void; updateCurrentMediaPath: (path: string) => void; restoreMpvSubVisibility: () => void; + resetSubtitleSidebarEmbeddedLayout?: () => void; getCurrentAnilistMediaKey: () => string | null; resetAnilistMediaTracking: (mediaKey: string | null) => void; maybeProbeAnilistDuration: (mediaKey: string) => void; @@ -146,6 +147,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { deps.broadcastToOverlayWindows('secondary-subtitle:set', text), updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path), restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(), + resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout?.(), getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(), resetAnilistMediaTracking: (mediaKey: string | null) => deps.resetAnilistMediaTracking(mediaKey), diff --git a/src/main/runtime/subtitle-prefetch-init.test.ts b/src/main/runtime/subtitle-prefetch-init.test.ts index e076d1c..34debc4 100644 --- a/src/main/runtime/subtitle-prefetch-init.test.ts +++ b/src/main/runtime/subtitle-prefetch-init.test.ts @@ -112,3 +112,90 @@ test('cancelPendingInit prevents an in-flight load from attaching a stale servic assert.equal(currentService, null); assert.deepEqual(started, []); }); + +test('subtitle prefetch init publishes parsed cues and clears them on cancel', async () => { + const deferred = createDeferred(); + let currentService: SubtitlePrefetchService | null = null; + const cueUpdates: Array = []; + + 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(); + let currentService: SubtitlePrefetchService | null = null; + const sourceUpdates: Array = []; + + 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']); +}); diff --git a/src/main/runtime/subtitle-prefetch-init.ts b/src/main/runtime/subtitle-prefetch-init.ts index 5d11b30..dcc8a32 100644 --- a/src/main/runtime/subtitle-prefetch-init.ts +++ b/src/main/runtime/subtitle-prefetch-init.ts @@ -16,11 +16,16 @@ export interface SubtitlePrefetchInitControllerDeps { isCacheFull: () => boolean; logInfo: (message: string) => void; logWarn: (message: string) => void; + onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void; } export interface SubtitlePrefetchInitController { cancelPendingInit: () => void; - initSubtitlePrefetch: (externalFilename: string, currentTimePos: number) => Promise; + initSubtitlePrefetch: ( + sourcePath: string, + currentTimePos: number, + sourceKey?: string, + ) => Promise; } export function createSubtitlePrefetchInitController( @@ -32,24 +37,29 @@ export function createSubtitlePrefetchInitController( initRevision += 1; deps.getCurrentService()?.stop(); deps.setCurrentService(null); + deps.onParsedSubtitleCuesChanged?.(null, null); }; const initSubtitlePrefetch = async ( - externalFilename: string, + sourcePath: string, currentTimePos: number, + sourceKey = sourcePath, ): Promise => { const revision = ++initRevision; deps.getCurrentService()?.stop(); deps.setCurrentService(null); try { - const content = await deps.loadSubtitleSourceText(externalFilename); + const content = await deps.loadSubtitleSourceText(sourcePath); if (revision !== initRevision) { return; } - const cues = deps.parseSubtitleCues(content, externalFilename); + const cues = deps.parseSubtitleCues(content, sourcePath); if (revision !== initRevision || cues.length === 0) { + if (revision === initRevision) { + deps.onParsedSubtitleCuesChanged?.(null, null); + } return; } @@ -65,9 +75,10 @@ export function createSubtitlePrefetchInitController( } deps.setCurrentService(nextService); + deps.onParsedSubtitleCuesChanged?.(cues, sourceKey); nextService.start(currentTimePos); deps.logInfo( - `[subtitle-prefetch] started prefetching ${cues.length} cues from ${externalFilename}`, + `[subtitle-prefetch] started prefetching ${cues.length} cues from ${sourcePath}`, ); } catch (error) { if (revision === initRevision) { diff --git a/src/main/runtime/subtitle-prefetch-source.test.ts b/src/main/runtime/subtitle-prefetch-source.test.ts index e031437..c66362a 100644 --- a/src/main/runtime/subtitle-prefetch-source.test.ts +++ b/src/main/runtime/subtitle-prefetch-source.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + buildSubtitleSidebarSourceKey, getActiveExternalSubtitleSource, resolveSubtitleSourcePath, } from './subtitle-prefetch-source'; @@ -48,3 +49,29 @@ test('resolveSubtitleSourcePath returns the original source for malformed file U 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'); +}); diff --git a/src/main/runtime/subtitle-prefetch-source.ts b/src/main/runtime/subtitle-prefetch-source.ts index b740ff6..a7b4ede 100644 --- a/src/main/runtime/subtitle-prefetch-source.ts +++ b/src/main/runtime/subtitle-prefetch-source.ts @@ -1,5 +1,16 @@ 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( trackListRaw: unknown, sidRaw: unknown, @@ -19,7 +30,7 @@ export function getActiveExternalSubtitleSource( return false; } const track = entry as Record; - return track.type === 'sub' && track.id === sid && track.external === true; + return track.type === 'sub' && track.id === sid; }) as Record | undefined; const externalFilename = @@ -40,3 +51,21 @@ export function resolveSubtitleSourcePath(source: string): string { 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; + 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; +} diff --git a/src/main/state.ts b/src/main/state.ts index d8c9081..b570c5e 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -5,6 +5,7 @@ import type { MpvSubtitleRenderMetrics, SecondarySubMode, SubtitleData, + SubtitleCue, SubtitlePosition, KikuFieldGroupingChoice, JlptLevel, @@ -158,6 +159,8 @@ export interface AppState { currentSubText: string; currentSubAssText: string; currentSubtitleData: SubtitleData | null; + activeParsedSubtitleCues: SubtitleCue[]; + activeParsedSubtitleSource: string | null; windowTracker: BaseWindowTracker | null; subtitlePosition: SubtitlePosition | null; currentMediaPath: string | null; @@ -238,6 +241,8 @@ export function createAppState(values: AppStateInitialValues): AppState { currentSubText: '', currentSubAssText: '', currentSubtitleData: null, + activeParsedSubtitleCues: [], + activeParsedSubtitleSource: null, windowTracker: null, subtitlePosition: null, currentMediaPath: null, diff --git a/src/preload.ts b/src/preload.ts index 55b3dd7..ba063ab 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -169,6 +169,8 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw), getCurrentSubtitleAss: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss), + getSubtitleSidebarSnapshot: () => + ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot), getPlaybackPaused: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused), onSubtitleAss: (callback: (assText: string) => void) => { diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 853596a..0afb5ae 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -27,6 +27,7 @@ export function createKeyboardHandlers( getPlaybackPaused: () => Promise; openControllerSelectModal: () => void; openControllerDebugModal: () => void; + toggleSubtitleSidebarModal?: () => void; }, ) { // 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'; } + 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 { return ( e.code === ctx.state.statsToggleKey && @@ -838,6 +859,12 @@ export function createKeyboardHandlers( return; } + if (isSubtitleSidebarToggle(e)) { + e.preventDefault(); + options.toggleSubtitleSidebarModal?.(); + return; + } + if ( (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) && !isControllerModalShortcut(e) diff --git a/src/renderer/index.html b/src/renderer/index.html index 2eae08d..142512f 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -256,6 +256,18 @@ +