mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-20 17:01:24 -07:00
fix(overlay): poll cheap playback state in the open subtitle sidebar (#290)
This commit is contained in:
1 parent
7108e086ba
commit
a6d96e9966
14 files changed
+286
-108
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- The open subtitle sidebar no longer re-fetches the whole subtitle list and re-queries mpv about 12 times a second. It polls only the current time and line, and reloads the list when the subtitle track, file, or parsed cues change (plus a slow ~2 s fallback), cutting idle sidebar IPC and renderer work during playback.
|
||||||
@@ -63,7 +63,11 @@ the plugin artifact, persistent config, or session help.
|
|||||||
|
|
||||||
The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey`
|
The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey`
|
||||||
identifies the media and subtitle source so renderer selections are invalidated on source changes,
|
identifies the media and subtitle source so renderer selections are invalidated on source changes,
|
||||||
including changes whose cue text and timings are identical. Native selection and clean clipboard
|
including changes whose cue text and timings are identical. While open, the sidebar polls the
|
||||||
|
cheap `SubtitleSidebarPlaybackState` (cached time, current line, `sourceKey`, `cuesRevision`; no
|
||||||
|
mpv round-trips) every 80 ms for active-cue tracking. It fetches the full snapshot, which resolves
|
||||||
|
the source over mpv IPC and carries every cue plus config, only on open, config reload, a
|
||||||
|
`sourceKey`/`cuesRevision` change, and a ~2 s fallback. Native selection and clean clipboard
|
||||||
serialization live in `src/renderer/modals/subtitle-sidebar-selection.ts`. Electron lets standard
|
serialization live in `src/renderer/modals/subtitle-sidebar-selection.ts`. Electron lets standard
|
||||||
Copy input reach the renderer, where sidebar selection takes priority over the live-subtitle binding.
|
Copy input reach the renderer, where sidebar selection takes priority over the live-subtitle binding.
|
||||||
The preload bridge writes selections through Electron's clipboard API so copying does not depend
|
The preload bridge writes selections through Electron's clipboard API so copying does not depend
|
||||||
|
|||||||
@@ -156,6 +156,34 @@ test(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'open subtitle sidebar lists the parsed cues and follows playback',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const { session, overlay } = await useSession();
|
||||||
|
await showCue(FIXTURE_CUES[0]);
|
||||||
|
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||||
|
await overlay.waitFor(
|
||||||
|
`document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item').length === ${FIXTURE_CUES.length}`,
|
||||||
|
{ description: 'sidebar to list every fixture cue' },
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [index, cue] of FIXTURE_CUES.entries()) {
|
||||||
|
await session.mpv.command('seek', cue.at, 'absolute+exact');
|
||||||
|
await overlay.waitFor(
|
||||||
|
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '${index}'`,
|
||||||
|
{ description: `sidebar to highlight cue ${index}` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||||
|
await overlay.waitFor(
|
||||||
|
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
|
||||||
|
{ description: 'sidebar to close' },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Runs last: it leaves the lookup popup open over the overlay.
|
// Runs last: it leaves the lookup popup open over the overlay.
|
||||||
test(
|
test(
|
||||||
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
||||||
|
|||||||
@@ -86,7 +86,9 @@ function createControllerConfigFixture() {
|
|||||||
function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
|
function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
|
||||||
return {
|
return {
|
||||||
sourceKey: 'test-subtitles',
|
sourceKey: 'test-subtitles',
|
||||||
|
cuesRevision: 1,
|
||||||
cues: [],
|
cues: [],
|
||||||
|
currentTimeSec: null,
|
||||||
currentSubtitle: { text: '', startTime: null, endTime: null },
|
currentSubtitle: { text: '', startTime: null, endTime: null },
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
RuntimeOptionId,
|
RuntimeOptionId,
|
||||||
RuntimeOptionValue,
|
RuntimeOptionValue,
|
||||||
SubtitleMiningContext,
|
SubtitleMiningContext,
|
||||||
|
SubtitleSidebarPlaybackState,
|
||||||
SubtitleSidebarSnapshot,
|
SubtitleSidebarSnapshot,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
SubsyncManualRunRequest,
|
SubsyncManualRunRequest,
|
||||||
@@ -79,6 +80,7 @@ export interface IpcServiceDeps {
|
|||||||
getCurrentSubtitleRaw: () => string;
|
getCurrentSubtitleRaw: () => string;
|
||||||
getCurrentSubtitleAss: () => string;
|
getCurrentSubtitleAss: () => string;
|
||||||
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
|
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
|
||||||
|
getSubtitleSidebarPlaybackState?: () => SubtitleSidebarPlaybackState;
|
||||||
getSubtitleSidebarOpen?: () => boolean;
|
getSubtitleSidebarOpen?: () => boolean;
|
||||||
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
|
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
|
||||||
getSubtitlePosition: () => unknown;
|
getSubtitlePosition: () => unknown;
|
||||||
@@ -369,6 +371,7 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
getCurrentSubtitleRaw: () => string;
|
getCurrentSubtitleRaw: () => string;
|
||||||
getCurrentSubtitleAss: () => string;
|
getCurrentSubtitleAss: () => string;
|
||||||
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
|
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
|
||||||
|
getSubtitleSidebarPlaybackState?: () => SubtitleSidebarPlaybackState;
|
||||||
getSubtitleSidebarOpen?: () => boolean;
|
getSubtitleSidebarOpen?: () => boolean;
|
||||||
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
|
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
|
||||||
getSubtitlePosition: () => unknown;
|
getSubtitlePosition: () => unknown;
|
||||||
@@ -455,6 +458,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
|
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
|
||||||
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
|
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
|
||||||
getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot,
|
getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot,
|
||||||
|
getSubtitleSidebarPlaybackState: options.getSubtitleSidebarPlaybackState,
|
||||||
getSubtitleSidebarOpen: options.getSubtitleSidebarOpen ?? (() => false),
|
getSubtitleSidebarOpen: options.getSubtitleSidebarOpen ?? (() => false),
|
||||||
getPlaybackPaused: options.getPlaybackPaused,
|
getPlaybackPaused: options.getPlaybackPaused,
|
||||||
getSubtitlePosition: options.getSubtitlePosition,
|
getSubtitlePosition: options.getSubtitlePosition,
|
||||||
@@ -742,6 +746,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
return await deps.getSubtitleSidebarSnapshot();
|
return await deps.getSubtitleSidebarSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarPlaybackState, () => {
|
||||||
|
if (!deps.getSubtitleSidebarPlaybackState) {
|
||||||
|
throw new Error('Subtitle sidebar playback state is unavailable.');
|
||||||
|
}
|
||||||
|
return deps.getSubtitleSidebarPlaybackState();
|
||||||
|
});
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarOpen, () => {
|
ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarOpen, () => {
|
||||||
return deps.getSubtitleSidebarOpen?.() ?? false;
|
return deps.getSubtitleSidebarOpen?.() ?? false;
|
||||||
});
|
});
|
||||||
|
|||||||
+44
-76
@@ -92,8 +92,10 @@ import type {
|
|||||||
SessionActionDispatchRequest,
|
SessionActionDispatchRequest,
|
||||||
SecondarySubMode,
|
SecondarySubMode,
|
||||||
SubtitleData,
|
SubtitleData,
|
||||||
|
SubtitleCue,
|
||||||
SubtitleMiningContext,
|
SubtitleMiningContext,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
|
SubtitleSidebarPlaybackState,
|
||||||
WindowGeometry,
|
WindowGeometry,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { OPEN_ANKI_CARD_ACTION_ID } from './types';
|
import { OPEN_ANKI_CARD_ACTION_ID } from './types';
|
||||||
@@ -2047,6 +2049,33 @@ const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime({
|
|||||||
logDebug: (message) => logger.debug(message),
|
logDebug: (message) => logger.debug(message),
|
||||||
});
|
});
|
||||||
let subtitleSidebarRequestedOpen = false;
|
let subtitleSidebarRequestedOpen = false;
|
||||||
|
let subtitleSidebarCuesRef: SubtitleCue[] | null = null;
|
||||||
|
let subtitleSidebarCuesRevision = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap sidebar state polled ~12x/s while the sidebar is open. Reads only cached state, no
|
||||||
|
* mpv round-trips. `cuesRevision` bumps whenever a new parsed cue array is swapped in, so a
|
||||||
|
* re-parse of the same source still tells the renderer to fetch the full snapshot.
|
||||||
|
*/
|
||||||
|
function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState {
|
||||||
|
if (appState.activeParsedSubtitleCues !== subtitleSidebarCuesRef) {
|
||||||
|
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
|
||||||
|
subtitleSidebarCuesRevision += 1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sourceKey: JSON.stringify([
|
||||||
|
appState.activeParsedSubtitleMediaPath,
|
||||||
|
appState.activeParsedSubtitleSource,
|
||||||
|
]),
|
||||||
|
cuesRevision: subtitleSidebarCuesRevision,
|
||||||
|
currentTimeSec: appState.mpvClient?.currentTimePos ?? null,
|
||||||
|
currentSubtitle: {
|
||||||
|
text: appState.currentSubText,
|
||||||
|
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||||
|
endTime: appState.mpvClient?.currentSubEnd ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
const SEEK_THRESHOLD_SECONDS = 3;
|
const SEEK_THRESHOLD_SECONDS = 3;
|
||||||
const EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
|
const EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
|
||||||
let explicitSeekIntentExpiresAtMs = 0;
|
let explicitSeekIntentExpiresAtMs = 0;
|
||||||
@@ -5850,26 +5879,19 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
getCurrentSubtitleRaw: () => appState.currentSubText,
|
getCurrentSubtitleRaw: () => appState.currentSubText,
|
||||||
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
||||||
getSubtitleSidebarOpen: () => subtitleSidebarRequestedOpen,
|
getSubtitleSidebarOpen: () => subtitleSidebarRequestedOpen,
|
||||||
|
getSubtitleSidebarPlaybackState: () => getSubtitleSidebarPlaybackState(),
|
||||||
|
// Full snapshot: resolves the active source over mpv IPC and re-parses it when it changed.
|
||||||
|
// The renderer fetches it on open, on source/cue changes, and on a slow fallback cadence.
|
||||||
getSubtitleSidebarSnapshot: async () => {
|
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 = configService.getConfig().subtitleSidebar;
|
const config = configService.getConfig().subtitleSidebar;
|
||||||
|
const cachedSnapshot = () => ({
|
||||||
|
...getSubtitleSidebarPlaybackState(),
|
||||||
|
cues: appState.activeParsedSubtitleCues,
|
||||||
|
config,
|
||||||
|
});
|
||||||
const client = appState.mpvClient;
|
const client = appState.mpvClient;
|
||||||
if (!client?.connected) {
|
if (!client?.connected) {
|
||||||
return {
|
return cachedSnapshot();
|
||||||
sourceKey: JSON.stringify([
|
|
||||||
appState.activeParsedSubtitleMediaPath,
|
|
||||||
appState.activeParsedSubtitleSource,
|
|
||||||
]),
|
|
||||||
cues: appState.activeParsedSubtitleCues,
|
|
||||||
currentTimeSec,
|
|
||||||
currentSubtitle,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -5883,16 +5905,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
]);
|
]);
|
||||||
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
|
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
|
||||||
if (!videoPath) {
|
if (!videoPath) {
|
||||||
return {
|
return cachedSnapshot();
|
||||||
sourceKey: JSON.stringify([
|
|
||||||
appState.activeParsedSubtitleMediaPath,
|
|
||||||
appState.activeParsedSubtitleSource,
|
|
||||||
]),
|
|
||||||
cues: appState.activeParsedSubtitleCues,
|
|
||||||
currentTimeSec,
|
|
||||||
currentSubtitle,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
shouldUseCachedYoutubeParsedCues({
|
shouldUseCachedYoutubeParsedCues({
|
||||||
@@ -5901,16 +5914,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
cachedCueCount: appState.activeParsedSubtitleCues.length,
|
cachedCueCount: appState.activeParsedSubtitleCues.length,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return {
|
return cachedSnapshot();
|
||||||
sourceKey: JSON.stringify([
|
|
||||||
appState.activeParsedSubtitleMediaPath,
|
|
||||||
appState.activeParsedSubtitleSource,
|
|
||||||
]),
|
|
||||||
cues: appState.activeParsedSubtitleCues,
|
|
||||||
currentTimeSec,
|
|
||||||
currentSubtitle,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({
|
const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({
|
||||||
@@ -5921,30 +5925,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
videoPath,
|
videoPath,
|
||||||
});
|
});
|
||||||
if (!resolvedSource) {
|
if (!resolvedSource) {
|
||||||
return {
|
return cachedSnapshot();
|
||||||
sourceKey: JSON.stringify([
|
|
||||||
appState.activeParsedSubtitleMediaPath,
|
|
||||||
appState.activeParsedSubtitleSource,
|
|
||||||
]),
|
|
||||||
cues: appState.activeParsedSubtitleCues,
|
|
||||||
currentTimeSec,
|
|
||||||
currentSubtitle,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
|
if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) {
|
||||||
return {
|
return cachedSnapshot();
|
||||||
sourceKey: JSON.stringify([
|
|
||||||
appState.activeParsedSubtitleMediaPath,
|
|
||||||
appState.activeParsedSubtitleSource,
|
|
||||||
]),
|
|
||||||
cues: appState.activeParsedSubtitleCues,
|
|
||||||
currentTimeSec,
|
|
||||||
currentSubtitle,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = await loadSubtitleSourceText(resolvedSource.path);
|
const content = await loadSubtitleSourceText(resolvedSource.path);
|
||||||
@@ -5952,30 +5938,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
appState.activeParsedSubtitleCues = cues;
|
appState.activeParsedSubtitleCues = cues;
|
||||||
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
|
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
|
||||||
appState.activeParsedSubtitleMediaPath = videoPath || null;
|
appState.activeParsedSubtitleMediaPath = videoPath || null;
|
||||||
return {
|
return cachedSnapshot();
|
||||||
sourceKey: JSON.stringify([
|
|
||||||
appState.activeParsedSubtitleMediaPath,
|
|
||||||
appState.activeParsedSubtitleSource,
|
|
||||||
]),
|
|
||||||
cues,
|
|
||||||
currentTimeSec,
|
|
||||||
currentSubtitle,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
} finally {
|
} finally {
|
||||||
await resolvedSource.cleanup?.();
|
await resolvedSource.cleanup?.();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return cachedSnapshot();
|
||||||
sourceKey: JSON.stringify([
|
|
||||||
appState.activeParsedSubtitleMediaPath,
|
|
||||||
appState.activeParsedSubtitleSource,
|
|
||||||
]),
|
|
||||||
cues: appState.activeParsedSubtitleCues,
|
|
||||||
currentTimeSec,
|
|
||||||
currentSubtitle,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getPlaybackPaused: () =>
|
getPlaybackPaused: () =>
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
|
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
|
||||||
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
|
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
|
||||||
getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot'];
|
getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot'];
|
||||||
|
getSubtitleSidebarPlaybackState?: IpcDepsRuntimeOptions['getSubtitleSidebarPlaybackState'];
|
||||||
getSubtitleSidebarOpen?: IpcDepsRuntimeOptions['getSubtitleSidebarOpen'];
|
getSubtitleSidebarOpen?: IpcDepsRuntimeOptions['getSubtitleSidebarOpen'];
|
||||||
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
|
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
|
||||||
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
|
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
|
||||||
@@ -276,6 +277,7 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
|
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
|
||||||
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
|
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
|
||||||
getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot,
|
getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot,
|
||||||
|
getSubtitleSidebarPlaybackState: params.getSubtitleSidebarPlaybackState,
|
||||||
getSubtitleSidebarOpen: params.getSubtitleSidebarOpen,
|
getSubtitleSidebarOpen: params.getSubtitleSidebarOpen,
|
||||||
getPlaybackPaused: params.getPlaybackPaused,
|
getPlaybackPaused: params.getPlaybackPaused,
|
||||||
getSubtitlePosition: params.getSubtitlePosition,
|
getSubtitlePosition: params.getSubtitlePosition,
|
||||||
|
|||||||
@@ -256,6 +256,8 @@ const electronAPI: ElectronAPI = {
|
|||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarOpen),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarOpen),
|
||||||
getSubtitleSidebarSnapshot: () =>
|
getSubtitleSidebarSnapshot: () =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot),
|
||||||
|
getSubtitleSidebarPlaybackState: () =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarPlaybackState),
|
||||||
copySubtitleSidebarSelection: async (text: unknown) => {
|
copySubtitleSidebarSelection: async (text: unknown) => {
|
||||||
if (typeof text !== 'string') throw new TypeError('Subtitle selection must be text.');
|
if (typeof text !== 'string') throw new TypeError('Subtitle selection must be text.');
|
||||||
clipboard.writeText(text);
|
clipboard.writeText(text);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export async function setup() {
|
|||||||
const commands: unknown[] = [];
|
const commands: unknown[] = [];
|
||||||
const snapshot: SubtitleSidebarSnapshot = {
|
const snapshot: SubtitleSidebarSnapshot = {
|
||||||
sourceKey: 'episode-1:track-1',
|
sourceKey: 'episode-1:track-1',
|
||||||
|
cuesRevision: 1,
|
||||||
cues: [
|
cues: [
|
||||||
{ text: '最初の台詞', startTime: 0, endTime: 1 },
|
{ text: '最初の台詞', startTime: 0, endTime: 1 },
|
||||||
{ text: '同じ台詞\n二行目', startTime: 1, endTime: 2 },
|
{ text: '同じ台詞\n二行目', startTime: 1, endTime: 2 },
|
||||||
@@ -39,6 +40,7 @@ export async function setup() {
|
|||||||
Object.defineProperty(window, 'electronAPI', {
|
Object.defineProperty(window, 'electronAPI', {
|
||||||
value: {
|
value: {
|
||||||
getSubtitleSidebarSnapshot: async () => snapshot,
|
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||||
|
getSubtitleSidebarPlaybackState: async () => snapshot,
|
||||||
getSessionBindings: async () => [
|
getSessionBindings: async () => [
|
||||||
{
|
{
|
||||||
sourcePath: 'keybindings[0].key',
|
sourcePath: 'keybindings[0].key',
|
||||||
@@ -64,6 +66,7 @@ export async function setup() {
|
|||||||
} satisfies Pick<
|
} satisfies Pick<
|
||||||
ElectronAPI,
|
ElectronAPI,
|
||||||
| 'getSubtitleSidebarSnapshot'
|
| 'getSubtitleSidebarSnapshot'
|
||||||
|
| 'getSubtitleSidebarPlaybackState'
|
||||||
| 'getSessionBindings'
|
| 'getSessionBindings'
|
||||||
| 'getConfiguredShortcuts'
|
| 'getConfiguredShortcuts'
|
||||||
| 'getStatsToggleKey'
|
| 'getStatsToggleKey'
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types';
|
import type {
|
||||||
|
ElectronAPI,
|
||||||
|
SubtitleSidebarPlaybackState,
|
||||||
|
SubtitleSidebarSnapshot,
|
||||||
|
} from '../../types';
|
||||||
import { createRendererState } from '../state.js';
|
import { createRendererState } from '../state.js';
|
||||||
import {
|
import {
|
||||||
applySidebarCssDeclarations,
|
applySidebarCssDeclarations,
|
||||||
@@ -68,7 +72,12 @@ function createCueRow() {
|
|||||||
|
|
||||||
function createListStub() {
|
function createListStub() {
|
||||||
return {
|
return {
|
||||||
innerHTML: '',
|
get innerHTML() {
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
set innerHTML(_html: string) {
|
||||||
|
this.children.length = 0;
|
||||||
|
},
|
||||||
children: [] as ReturnType<typeof createCueRow>[],
|
children: [] as ReturnType<typeof createCueRow>[],
|
||||||
appendChild(child: ReturnType<typeof createCueRow>) {
|
appendChild(child: ReturnType<typeof createCueRow>) {
|
||||||
child.offsetTop = this.children.length * child.clientHeight;
|
child.offsetTop = this.children.length * child.clientHeight;
|
||||||
@@ -180,17 +189,20 @@ test.afterEach(() => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a sidebar modal over fake window/document/dom globals. The snapshot served to the
|
* Builds a sidebar modal over fake window/document/dom globals. The snapshot served to the
|
||||||
* modal can be changed with `setSnapshot`. Globals are restored (and polling stopped) after
|
* modal (and the playback state derived from it) can be changed with `setSnapshot`; `poll`
|
||||||
* each test.
|
* runs the next scheduled playback tick. Globals are restored after each test.
|
||||||
*/
|
*/
|
||||||
function createSidebarHarness(options: SidebarHarnessOptions = {}) {
|
function createSidebarHarness(options: SidebarHarnessOptions = {}) {
|
||||||
let snapshot: SubtitleSidebarSnapshot = {
|
let snapshot: SubtitleSidebarSnapshot = {
|
||||||
sourceKey: 'test-subtitles',
|
sourceKey: 'test-subtitles',
|
||||||
|
cuesRevision: 1,
|
||||||
cues: options.cues ?? [{ startTime: 1, endTime: 2, text: 'first' }],
|
cues: options.cues ?? [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||||
currentSubtitle: options.currentSubtitle ?? { text: 'first', startTime: 1, endTime: 2 },
|
currentSubtitle: options.currentSubtitle ?? { text: 'first', startTime: 1, endTime: 2 },
|
||||||
currentTimeSec: 'currentTimeSec' in options ? options.currentTimeSec : 1.1,
|
currentTimeSec: 'currentTimeSec' in options ? (options.currentTimeSec ?? null) : 1.1,
|
||||||
config: { ...DEFAULT_SIDEBAR_CONFIG, ...options.config },
|
config: { ...DEFAULT_SIDEBAR_CONFIG, ...options.config },
|
||||||
};
|
};
|
||||||
|
const requests = { snapshot: 0, playback: 0 };
|
||||||
|
let nextPoll: (() => Promise<void>) | null = null;
|
||||||
|
|
||||||
const mpvCommands: MpvCommand[] = [];
|
const mpvCommands: MpvCommand[] = [];
|
||||||
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
|
const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = [];
|
||||||
@@ -208,7 +220,15 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
|
|||||||
addEventListener: windowEvents.addEventListener,
|
addEventListener: windowEvents.addEventListener,
|
||||||
removeEventListener: windowEvents.removeEventListener,
|
removeEventListener: windowEvents.removeEventListener,
|
||||||
electronAPI: {
|
electronAPI: {
|
||||||
getSubtitleSidebarSnapshot: async () => snapshot,
|
getSubtitleSidebarSnapshot: async () => {
|
||||||
|
requests.snapshot += 1;
|
||||||
|
return snapshot;
|
||||||
|
},
|
||||||
|
getSubtitleSidebarPlaybackState: async (): Promise<SubtitleSidebarPlaybackState> => {
|
||||||
|
requests.playback += 1;
|
||||||
|
const { sourceKey, cuesRevision, currentTimeSec, currentSubtitle } = snapshot;
|
||||||
|
return { sourceKey, cuesRevision, currentTimeSec, currentSubtitle };
|
||||||
|
},
|
||||||
getPlaybackPaused: async () => false,
|
getPlaybackPaused: async () => false,
|
||||||
sendMpvCommand: (command: MpvCommand) => {
|
sendMpvCommand: (command: MpvCommand) => {
|
||||||
mpvCommands.push(command);
|
mpvCommands.push(command);
|
||||||
@@ -281,11 +301,16 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
|
|||||||
onVisibilityChanged: (visible) => {
|
onVisibilityChanged: (visible) => {
|
||||||
visibilityChanges.push(visible);
|
visibilityChanges.push(visible);
|
||||||
},
|
},
|
||||||
|
schedule: (callback) => {
|
||||||
|
nextPoll = callback;
|
||||||
|
return () => {
|
||||||
|
if (nextPoll === callback) nextPoll = null;
|
||||||
|
};
|
||||||
|
},
|
||||||
...options.modalOptions,
|
...options.modalOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
harnessCleanups.push(() => {
|
harnessCleanups.push(() => {
|
||||||
// Closing stops the snapshot poll so it cannot fire against the next test's globals.
|
|
||||||
modal.closeSubtitleSidebarModal();
|
modal.closeSubtitleSidebarModal();
|
||||||
modal.disposeDomEvents();
|
modal.disposeDomEvents();
|
||||||
restoreGlobals();
|
restoreGlobals();
|
||||||
@@ -307,6 +332,13 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
|
|||||||
windowEvents,
|
windowEvents,
|
||||||
contentEvents,
|
contentEvents,
|
||||||
modalEvents,
|
modalEvents,
|
||||||
|
requests,
|
||||||
|
/** Runs the pending playback poll tick, if one is scheduled. */
|
||||||
|
async poll() {
|
||||||
|
const tick = nextPoll;
|
||||||
|
nextPoll = null;
|
||||||
|
await tick?.();
|
||||||
|
},
|
||||||
setSnapshot(patch: SnapshotPatch) {
|
setSnapshot(patch: SnapshotPatch) {
|
||||||
snapshot = { ...snapshot, ...patch, config: { ...snapshot.config, ...patch.config } };
|
snapshot = { ...snapshot, ...patch, config: { ...snapshot.config, ...patch.config } };
|
||||||
},
|
},
|
||||||
@@ -675,7 +707,7 @@ test('subtitle sidebar jumps to first resolved active cue, then resumes smooth a
|
|||||||
currentSubtitle: { text: 'line-9', startTime: 18, endTime: 19.5 },
|
currentSubtitle: { text: 'line-9', startTime: 18, endTime: 19.5 },
|
||||||
currentTimeSec: 18.1,
|
currentTimeSec: 18.1,
|
||||||
});
|
});
|
||||||
await h.modal.refreshSubtitleSidebarSnapshot();
|
await h.poll();
|
||||||
|
|
||||||
assert.equal(h.state.subtitleSidebarActiveCueIndex, 9);
|
assert.equal(h.state.subtitleSidebarActiveCueIndex, 9);
|
||||||
assert.equal(h.cueList.scrollTop, 260);
|
assert.equal(h.cueList.scrollTop, 260);
|
||||||
@@ -685,12 +717,94 @@ test('subtitle sidebar jumps to first resolved active cue, then resumes smooth a
|
|||||||
currentSubtitle: { text: 'line-10', startTime: 20, endTime: 21.5 },
|
currentSubtitle: { text: 'line-10', startTime: 20, endTime: 21.5 },
|
||||||
currentTimeSec: 20.1,
|
currentTimeSec: 20.1,
|
||||||
});
|
});
|
||||||
await h.modal.refreshSubtitleSidebarSnapshot();
|
await h.poll();
|
||||||
|
|
||||||
assert.equal(h.state.subtitleSidebarActiveCueIndex, 10);
|
assert.equal(h.state.subtitleSidebarActiveCueIndex, 10);
|
||||||
assert.deepEqual(h.cueList.scrollToCalls.at(-1), { top: 300, behavior: 'smooth' });
|
assert.deepEqual(h.cueList.scrollToCalls.at(-1), { top: 300, behavior: 'smooth' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('open subtitle sidebar follows playback from cheap polls without refetching cues or re-measuring', async () => {
|
||||||
|
let measurements = 0;
|
||||||
|
const h = createSidebarHarness({
|
||||||
|
cues: Array.from({ length: 4 }, (_, index) => ({
|
||||||
|
startTime: index * 2,
|
||||||
|
endTime: index * 2 + 1.5,
|
||||||
|
text: `line-${index}`,
|
||||||
|
})),
|
||||||
|
currentSubtitle: { text: 'line-0', startTime: 0, endTime: 1.5 },
|
||||||
|
currentTimeSec: 0.1,
|
||||||
|
config: { layout: 'embedded', maxWidth: 360 },
|
||||||
|
contentWidth: () => {
|
||||||
|
measurements += 1;
|
||||||
|
return 360;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await h.modal.openSubtitleSidebarModal();
|
||||||
|
const snapshotsAfterOpen = h.requests.snapshot;
|
||||||
|
const measurementsAfterOpen = measurements;
|
||||||
|
|
||||||
|
// Inside the lookahead window the upcoming cue activates before mpv reports its text.
|
||||||
|
h.setSnapshot({
|
||||||
|
currentSubtitle: { text: '', startTime: null, endTime: null },
|
||||||
|
currentTimeSec: 1.9,
|
||||||
|
});
|
||||||
|
await h.poll();
|
||||||
|
assert.equal(h.state.subtitleSidebarActiveCueIndex, 1);
|
||||||
|
|
||||||
|
h.setSnapshot({
|
||||||
|
currentSubtitle: { text: 'line-2', startTime: 4, endTime: 5.5 },
|
||||||
|
currentTimeSec: 4.1,
|
||||||
|
});
|
||||||
|
await h.poll();
|
||||||
|
assert.equal(h.state.subtitleSidebarActiveCueIndex, 2);
|
||||||
|
assert.equal(h.cueList.children[2]!.classList.contains('active'), true);
|
||||||
|
assert.equal(h.cueList.children[1]!.classList.contains('active'), false);
|
||||||
|
|
||||||
|
assert.equal(h.requests.playback, 2);
|
||||||
|
assert.equal(h.requests.snapshot, snapshotsAfterOpen);
|
||||||
|
assert.equal(measurements, measurementsAfterOpen);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const change of [
|
||||||
|
{ name: 'subtitle source', patch: { sourceKey: 'other-subtitles' } },
|
||||||
|
{ name: 'parsed cue revision', patch: { cuesRevision: 2 } },
|
||||||
|
]) {
|
||||||
|
test(`open subtitle sidebar refetches and re-renders cues when the ${change.name} changes`, async () => {
|
||||||
|
const h = createSidebarHarness();
|
||||||
|
await h.modal.openSubtitleSidebarModal();
|
||||||
|
const snapshotsAfterOpen = h.requests.snapshot;
|
||||||
|
|
||||||
|
h.setSnapshot({
|
||||||
|
...change.patch,
|
||||||
|
cues: [
|
||||||
|
{ startTime: 1, endTime: 2, text: 'replacement' },
|
||||||
|
{ startTime: 3, endTime: 4, text: 'second' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await h.poll();
|
||||||
|
|
||||||
|
assert.equal(h.requests.snapshot, snapshotsAfterOpen + 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
h.cueList.children.map((row) => (row.children[1] as { textContent: string }).textContent),
|
||||||
|
['replacement', 'second'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('open subtitle sidebar picks up changes main did not flag on a slow full-snapshot fallback', async () => {
|
||||||
|
const h = createSidebarHarness();
|
||||||
|
await h.modal.openSubtitleSidebarModal();
|
||||||
|
h.setSnapshot({ config: { enabled: false } });
|
||||||
|
|
||||||
|
for (let polls = 0; h.state.subtitleSidebarModalOpen && polls < 100; polls += 1) {
|
||||||
|
await h.poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(h.state.subtitleSidebarModalOpen, false);
|
||||||
|
assert.equal(h.requests.snapshot, 2);
|
||||||
|
assert.ok(h.requests.playback > 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitle sidebar closes and resumes a hover pause', async () => {
|
test('subtitle sidebar closes and resumes a hover pause', async () => {
|
||||||
const h = createSidebarHarness({ config: { pauseVideoOnHover: true } });
|
const h = createSidebarHarness({ config: { pauseVideoOnHover: true } });
|
||||||
h.modal.wireDomEvents();
|
h.modal.wireDomEvents();
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import {
|
|||||||
|
|
||||||
const MANUAL_SCROLL_HOLD_MS = 1500;
|
const MANUAL_SCROLL_HOLD_MS = 1500;
|
||||||
const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18;
|
const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18;
|
||||||
const SNAPSHOT_POLL_INTERVAL_MS = 80;
|
// While open, the sidebar polls cheap cached playback state for active-cue tracking and only
|
||||||
|
// fetches the full snapshot (mpv source resolution + every cue + config) when the source or
|
||||||
|
// parsed cues changed, plus this fallback in case main missed a change.
|
||||||
|
const PLAYBACK_POLL_INTERVAL_MS = 80;
|
||||||
|
const FULL_SNAPSHOT_FALLBACK_POLLS = 25;
|
||||||
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
|
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
|
||||||
const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45;
|
const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45;
|
||||||
const appliedSidebarCssKeys = new WeakMap<HTMLElement, Set<string>>();
|
const appliedSidebarCssKeys = new WeakMap<HTMLElement, Set<string>>();
|
||||||
@@ -206,9 +210,18 @@ export function createSubtitleSidebarModal(
|
|||||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||||
onVisibilityChanged?: (visible: boolean) => void;
|
onVisibilityChanged?: (visible: boolean) => void;
|
||||||
shouldRestoreOpenOnStartup?: () => Promise<boolean>;
|
shouldRestoreOpenOnStartup?: () => Promise<boolean>;
|
||||||
|
/** Schedules a poll tick and returns its cancel function. Defaults to `setTimeout`. */
|
||||||
|
schedule?: (callback: () => Promise<void>, delayMs: number) => () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
let snapshotPollInterval: ReturnType<typeof setTimeout> | null = null;
|
const schedule =
|
||||||
|
options.schedule ??
|
||||||
|
((callback: () => Promise<void>, delayMs: number) => {
|
||||||
|
const timer = setTimeout(callback, delayMs);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
|
let cancelPlaybackPoll: (() => void) | null = null;
|
||||||
|
let pollsSinceFullSnapshot = 0;
|
||||||
let lastAppliedVideoMarginRatio: number | null = null;
|
let lastAppliedVideoMarginRatio: number | null = null;
|
||||||
let subtitleSidebarHoverRequestId = 0;
|
let subtitleSidebarHoverRequestId = 0;
|
||||||
let disposeDomEvents: (() => void) | null = null;
|
let disposeDomEvents: (() => void) | null = null;
|
||||||
@@ -218,6 +231,7 @@ export function createSubtitleSidebarModal(
|
|||||||
let subtitleSidebarPauseHeldByYomitanPopup = false;
|
let subtitleSidebarPauseHeldByYomitanPopup = false;
|
||||||
let lastSubtitleSidebarLookupCueIndex = -1;
|
let lastSubtitleSidebarLookupCueIndex = -1;
|
||||||
let subtitleSourceKey: string | null = null;
|
let subtitleSourceKey: string | null = null;
|
||||||
|
let subtitleCuesRevision: number | null = null;
|
||||||
|
|
||||||
function restoreEmbeddedSidebarPassthrough(): void {
|
function restoreEmbeddedSidebarPassthrough(): void {
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
@@ -576,6 +590,8 @@ export function createSubtitleSidebarModal(
|
|||||||
|
|
||||||
async function refreshSnapshot(): Promise<SubtitleSidebarSnapshot> {
|
async function refreshSnapshot(): Promise<SubtitleSidebarSnapshot> {
|
||||||
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
|
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
|
||||||
|
pollsSinceFullSnapshot = 0;
|
||||||
|
subtitleCuesRevision = snapshot.cuesRevision;
|
||||||
if (snapshot.sourceKey !== subtitleSourceKey) {
|
if (snapshot.sourceKey !== subtitleSourceKey) {
|
||||||
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
|
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
|
||||||
lastSubtitleSidebarLookupCueIndex = -1;
|
lastSubtitleSidebarLookupCueIndex = -1;
|
||||||
@@ -590,8 +606,8 @@ export function createSubtitleSidebarModal(
|
|||||||
ctx.state.subtitleSidebarModalOpen = false;
|
ctx.state.subtitleSidebarModalOpen = false;
|
||||||
ctx.dom.subtitleSidebarModal.classList.add('hidden');
|
ctx.dom.subtitleSidebarModal.classList.add('hidden');
|
||||||
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
|
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
|
||||||
stopSnapshotPolling();
|
stopPlaybackPolling();
|
||||||
updateActiveCue(null, snapshot.currentTimeSec ?? null);
|
updateActiveCue(null, snapshot.currentTimeSec);
|
||||||
setStatus('Subtitle sidebar disabled in config.');
|
setStatus('Subtitle sidebar disabled in config.');
|
||||||
syncEmbeddedSidebarLayout();
|
syncEmbeddedSidebarLayout();
|
||||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||||
@@ -609,38 +625,49 @@ export function createSubtitleSidebarModal(
|
|||||||
renderCueList();
|
renderCueList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateActiveCue(snapshot.currentSubtitle, snapshot.currentTimeSec ?? null);
|
updateActiveCue(snapshot.currentSubtitle, snapshot.currentTimeSec);
|
||||||
syncEmbeddedSidebarLayout();
|
syncEmbeddedSidebarLayout();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startSnapshotPolling(): void {
|
async function pollPlaybackOnce(): Promise<void> {
|
||||||
stopSnapshotPolling();
|
pollsSinceFullSnapshot += 1;
|
||||||
|
const playback = await window.electronAPI.getSubtitleSidebarPlaybackState();
|
||||||
|
if (
|
||||||
|
playback.sourceKey !== subtitleSourceKey ||
|
||||||
|
playback.cuesRevision !== subtitleCuesRevision ||
|
||||||
|
pollsSinceFullSnapshot >= FULL_SNAPSHOT_FALLBACK_POLLS
|
||||||
|
) {
|
||||||
|
await refreshSnapshot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateActiveCue(playback.currentSubtitle, playback.currentTimeSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPlaybackPolling(): void {
|
||||||
|
stopPlaybackPolling();
|
||||||
|
|
||||||
const pollOnce = async (): Promise<void> => {
|
const pollOnce = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await refreshSnapshot();
|
await pollPlaybackOnce();
|
||||||
} catch {
|
} catch {
|
||||||
// Keep polling; a transient IPC failure should not stop updates.
|
// Keep polling; a transient IPC failure should not stop updates.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx.state.subtitleSidebarModalOpen) {
|
if (!ctx.state.subtitleSidebarModalOpen) {
|
||||||
snapshotPollInterval = null;
|
cancelPlaybackPoll = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS);
|
cancelPlaybackPoll = schedule(pollOnce, PLAYBACK_POLL_INTERVAL_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS);
|
cancelPlaybackPoll = schedule(pollOnce, PLAYBACK_POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopSnapshotPolling(): void {
|
function stopPlaybackPolling(): void {
|
||||||
if (!snapshotPollInterval) {
|
cancelPlaybackPoll?.();
|
||||||
return;
|
cancelPlaybackPoll = null;
|
||||||
}
|
|
||||||
clearTimeout(snapshotPollInterval);
|
|
||||||
snapshotPollInterval = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSubtitleSidebarModal(): Promise<void> {
|
async function openSubtitleSidebarModal(): Promise<void> {
|
||||||
@@ -664,7 +691,7 @@ export function createSubtitleSidebarModal(
|
|||||||
renderCueList();
|
renderCueList();
|
||||||
syncActiveCueClasses(-1);
|
syncActiveCueClasses(-1);
|
||||||
maybeAutoScrollActiveCue(-1, 'auto', true);
|
maybeAutoScrollActiveCue(-1, 'auto', true);
|
||||||
startSnapshotPolling();
|
startPlaybackPolling();
|
||||||
syncEmbeddedSidebarLayout();
|
syncEmbeddedSidebarLayout();
|
||||||
restoreEmbeddedSidebarPassthrough();
|
restoreEmbeddedSidebarPassthrough();
|
||||||
window.electronAPI.notifyOverlayModalOpened?.('subtitle-sidebar');
|
window.electronAPI.notifyOverlayModalOpened?.('subtitle-sidebar');
|
||||||
@@ -694,7 +721,7 @@ export function createSubtitleSidebarModal(
|
|||||||
ctx.state.subtitleSidebarModalOpen = false;
|
ctx.state.subtitleSidebarModalOpen = false;
|
||||||
ctx.dom.subtitleSidebarModal.classList.add('hidden');
|
ctx.dom.subtitleSidebarModal.classList.add('hidden');
|
||||||
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
|
ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true');
|
||||||
stopSnapshotPolling();
|
stopPlaybackPolling();
|
||||||
syncEmbeddedSidebarLayout();
|
syncEmbeddedSidebarLayout();
|
||||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||||
ctx.dom.overlay.classList.remove('interactive');
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const IPC_CHANNELS = {
|
|||||||
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',
|
getSubtitleSidebarSnapshot: 'get-subtitle-sidebar-snapshot',
|
||||||
|
getSubtitleSidebarPlaybackState: 'get-subtitle-sidebar-playback-state',
|
||||||
getSubtitleSidebarOpen: 'get-subtitle-sidebar-open',
|
getSubtitleSidebarOpen: 'get-subtitle-sidebar-open',
|
||||||
getPlaybackPaused: 'get-playback-paused',
|
getPlaybackPaused: 'get-playback-paused',
|
||||||
getSubtitlePosition: 'get-subtitle-position',
|
getSubtitlePosition: 'get-subtitle-position',
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import type {
|
|||||||
SubtitleData,
|
SubtitleData,
|
||||||
SubtitleMiningContext,
|
SubtitleMiningContext,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
|
SubtitleSidebarPlaybackState,
|
||||||
SubtitleSidebarSnapshot,
|
SubtitleSidebarSnapshot,
|
||||||
SubtitleRendererStyleConfig,
|
SubtitleRendererStyleConfig,
|
||||||
SubtitleStyleConfig,
|
SubtitleStyleConfig,
|
||||||
@@ -477,6 +478,7 @@ export interface ElectronAPI {
|
|||||||
getCurrentSubtitleRaw: () => Promise<string>;
|
getCurrentSubtitleRaw: () => Promise<string>;
|
||||||
getCurrentSubtitleAss: () => Promise<string>;
|
getCurrentSubtitleAss: () => Promise<string>;
|
||||||
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
|
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
|
||||||
|
getSubtitleSidebarPlaybackState: () => Promise<SubtitleSidebarPlaybackState>;
|
||||||
copySubtitleSidebarSelection: (text: string) => Promise<void>;
|
copySubtitleSidebarSelection: (text: string) => Promise<void>;
|
||||||
getSubtitleSidebarOpen: () => Promise<boolean>;
|
getSubtitleSidebarOpen: () => Promise<boolean>;
|
||||||
getPlaybackPaused: () => Promise<boolean | null>;
|
getPlaybackPaused: () => Promise<boolean | null>;
|
||||||
|
|||||||
+13
-3
@@ -226,15 +226,25 @@ export interface SubtitleData {
|
|||||||
endTime?: number | null;
|
endTime?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubtitleSidebarSnapshot {
|
/**
|
||||||
|
* Per-tick sidebar state, served from main's cached state without mpv round-trips. When
|
||||||
|
* `sourceKey` or `cuesRevision` differs from the last full snapshot's, the renderer fetches a
|
||||||
|
* new `SubtitleSidebarSnapshot`.
|
||||||
|
*/
|
||||||
|
export interface SubtitleSidebarPlaybackState {
|
||||||
sourceKey: string | null;
|
sourceKey: string | null;
|
||||||
cues: SubtitleCue[];
|
/** Bumps whenever main swaps in a different parsed cue array. */
|
||||||
currentTimeSec?: number | null;
|
cuesRevision: number;
|
||||||
|
currentTimeSec: number | null;
|
||||||
currentSubtitle: {
|
currentSubtitle: {
|
||||||
text: string;
|
text: string;
|
||||||
startTime: number | null;
|
startTime: number | null;
|
||||||
endTime: number | null;
|
endTime: number | null;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleSidebarSnapshot extends SubtitleSidebarPlaybackState {
|
||||||
|
cues: SubtitleCue[];
|
||||||
config: SubtitleSidebarSnapshotConfig;
|
config: SubtitleSidebarSnapshotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user