fix: delegate multi-line digit selection to visible overlay (#78)

This commit is contained in:
2026-05-24 00:39:23 -07:00
committed by GitHub
parent c02edc90cc
commit da3c971ee6
62 changed files with 1822 additions and 209 deletions
@@ -50,7 +50,7 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
assert.equal(leadInSeconds, 1.25);
});
test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audio duration', async () => {
test('resolveAnimatedImageLeadInSeconds does not double-count sentence audio padding', async () => {
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {
fields: {
@@ -87,7 +87,7 @@ test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audi
logWarn: () => undefined,
});
assert.equal(leadInSeconds, 1.75);
assert.equal(leadInSeconds, 1.25);
});
test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => {
+1 -9
View File
@@ -39,14 +39,6 @@ function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'med
return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false;
}
function resolveSentenceAudioStartOffsetSeconds(config: Pick<AnkiConnectConfig, 'media'>): number {
const configuredPadding = config.media?.audioPadding;
if (typeof configuredPadding === 'number' && Number.isFinite(configuredPadding)) {
return configuredPadding;
}
return DEFAULT_ANKI_CONNECT_CONFIG.media.audioPadding;
}
export async function probeAudioDurationSeconds(
buffer: Buffer,
filename: string,
@@ -135,5 +127,5 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
totalLeadInSeconds += durationSeconds;
}
return totalLeadInSeconds + resolveSentenceAudioStartOffsetSeconds(config);
return totalLeadInSeconds;
}
@@ -175,3 +175,99 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
assert.deepEqual(updatedFields[0], { Sentence: '字幕' });
assert.equal(mergeCalls.length, 0);
});
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
const audioPaths: string[] = [];
const imagePaths: string[] = [];
const edlSource = [
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
'!global_tags,title=test',
].join(';');
const { service, updatedFields, storedMedia } = createManualUpdateService({
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Sentence',
audio: 'ExpressionAudio',
image: 'Picture',
},
media: {
generateAudio: true,
generateImage: true,
imageFormat: 'jpg',
maxMediaDuration: 30,
},
behavior: {
overwriteAudio: false,
overwriteImage: false,
},
ai: false,
}) as AnkiConnectConfig,
getTimingTracker: () =>
({
findTiming: (text: string) => {
if (text === '一行目') return { startTime: 10, endTime: 12 };
if (text === '二行目') return { startTime: 12.5, endTime: 14 };
return null;
},
}) as never,
getMpvClient: () =>
({
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
currentTimePos: 13,
currentAudioStreamIndex: 0,
requestProperty: async (name: string) => {
assert.equal(name, 'stream-open-filename');
return edlSource;
},
}) as never,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: {
Expression: { value: '単語' },
Sentence: { value: '' },
ExpressionAudio: { value: '[sound:auto-expression.mp3]' },
SentenceAudio: { value: '[sound:auto-sentence.mp3]' },
Picture: { value: '' },
},
},
],
updateNoteFields: async (_noteId, fields) => {
updatedFields.push(fields);
},
storeMediaFile: async (filename) => {
storedMedia.push(filename);
},
findNotes: async () => [42],
retrieveMediaFile: async () => '',
},
mediaGenerator: {
generateAudio: async (path) => {
audioPaths.push(path);
return Buffer.from('audio');
},
generateScreenshot: async (path) => {
imagePaths.push(path);
return Buffer.from('image');
},
generateAnimatedImage: async () => null,
},
});
await service.updateLastAddedFromClipboard('一行目\n\n二行目');
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
assert.equal(storedMedia.length, 2);
assert.equal(updatedFields.length, 1);
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
assert.match(updatedFields[0]?.Picture ?? '', /^<img src="image_\d+\.jpg">$/);
});
+18 -11
View File
@@ -237,14 +237,19 @@ export class CardCreationService {
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
);
const audioSourcePath = this.deps.getConfig().media?.generateAudio
? await resolveMediaGenerationInputPath(mpvClient, 'audio')
: null;
const videoPath = this.deps.getConfig().media?.generateImage
? await resolveMediaGenerationInputPath(mpvClient, 'video')
: null;
if (this.deps.getConfig().media?.generateAudio) {
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.mediaGenerateAudio(
mpvClient.currentVideoPath,
rangeStart,
rangeEnd,
);
const audioBuffer = audioSourcePath
? await this.mediaGenerateAudio(audioSourcePath, rangeStart, rangeEnd)
: null;
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
@@ -271,12 +276,14 @@ export class CardCreationService {
try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImageBuffer(
mpvClient.currentVideoPath,
rangeStart,
rangeEnd,
animatedLeadInSeconds,
);
const imageBuffer = videoPath
? await this.generateImageBuffer(
videoPath,
rangeStart,
rangeEnd,
animatedLeadInSeconds,
)
: null;
if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
+1
View File
@@ -61,6 +61,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.texthooker.launchAtStartup, false);
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
assert.equal(config.ankiConnect.media.audioPadding, 0);
assert.equal(config.anilist.enabled, false);
assert.equal(config.anilist.characterDictionary.enabled, false);
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
@@ -51,7 +51,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
animatedMaxHeight: 0,
animatedCrf: 35,
syncAnimatedImageToWordAudio: true,
audioPadding: 0.5,
audioPadding: 0,
fallbackDuration: 3.0,
maxMediaDuration: 30,
},
+74
View File
@@ -6,6 +6,7 @@ import {
handleMultiCopyDigit,
mineSentenceCard,
} from './mining';
import { SubtitleTimingTracker } from '../../subtitle-timing-tracker';
test('copyCurrentSubtitle reports tracker and subtitle guards', () => {
const osd: string[] = [];
@@ -207,3 +208,76 @@ test('handleMineSentenceDigit increments successful card count', async () => {
assert.equal(cardsMined, 1);
});
test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => {
const created: Array<{ sentence: string; startTime: number; endTime: number }> = [];
const tracker = new SubtitleTimingTracker();
try {
tracker.recordSubtitle('same', 1, 2);
tracker.recordSubtitle('other', 3, 4);
tracker.recordSubtitle('same', 5, 6);
handleMineSentenceDigit(3, {
subtitleTimingTracker: tracker,
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async (sentence, startTime, endTime) => {
created.push({ sentence, startTime, endTime });
return true;
},
},
getCurrentSecondarySubText: () => undefined,
showMpvOsd: () => {},
logError: () => {},
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(created, [{ sentence: 'same other same', startTime: 1, endTime: 6 }]);
} finally {
tracker.destroy();
}
});
test('handleMineSentenceDigit joins per-entry secondary subtitles when available', async () => {
const created: Array<{ sentence: string; secondarySub?: string }> = [];
const tracker = new SubtitleTimingTracker();
const recordSubtitleWithSecondary = tracker.recordSubtitle as (
text: string,
startTime: number,
endTime: number,
secondaryText?: string,
) => void;
try {
recordSubtitleWithSecondary.call(tracker, 'one', 1, 2, 'translation one');
recordSubtitleWithSecondary.call(tracker, 'two', 3, 4, 'translation two');
handleMineSentenceDigit(2, {
subtitleTimingTracker: tracker,
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => {
created.push({ sentence, secondarySub });
return true;
},
},
getCurrentSecondarySubText: () => 'current translation only',
showMpvOsd: () => {},
logError: () => {},
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(created, [
{ sentence: 'one two', secondarySub: 'translation one translation two' },
]);
} finally {
tracker.destroy();
}
});
+30 -7
View File
@@ -1,5 +1,8 @@
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
interface SubtitleTimingTrackerLike {
getRecentBlocks: (count: number) => string[];
getRecentEntries?: (count: number) => SubtitleTimingBlock[];
getCurrentSubtitle: () => string | null;
findTiming: (text: string) => { startTime: number; endTime: number } | null;
}
@@ -79,6 +82,19 @@ function requireAnkiIntegration(
return ankiIntegration;
}
function getSecondarySubTextForMinedBlocks(
entries: SubtitleTimingBlock[] | undefined,
getCurrentSecondarySubText: () => string | undefined,
): string | undefined {
const secondaryBlocks = entries
?.map((entry) => entry.secondaryText?.trim())
.filter((text): text is string => Boolean(text));
if (secondaryBlocks && secondaryBlocks.length > 0) {
return secondaryBlocks.join(' ');
}
return getCurrentSecondarySubText();
}
export async function updateLastCardFromClipboard(deps: {
ankiIntegration: AnkiIntegrationLike | null;
readClipboardText: () => string;
@@ -146,17 +162,20 @@ export function handleMineSentenceDigit(
): void {
if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return;
const blocks = deps.subtitleTimingTracker.getRecentBlocks(count);
const entries = deps.subtitleTimingTracker.getRecentEntries?.(count);
const blocks =
entries?.map((entry) => entry.displayText) ?? deps.subtitleTimingTracker.getRecentBlocks(count);
if (blocks.length === 0) {
deps.showMpvOsd('No subtitle history available');
return;
}
const timings: { startTime: number; endTime: number }[] = [];
for (const block of blocks) {
const timing = deps.subtitleTimingTracker.findTiming(block);
if (timing) timings.push(timing);
}
const timings: { startTime: number; endTime: number }[] =
entries ??
blocks.flatMap((block) => {
const timing = deps.subtitleTimingTracker?.findTiming(block);
return timing ? [timing] : [];
});
if (timings.length === 0) {
deps.showMpvOsd('Subtitle timing not found');
@@ -166,9 +185,13 @@ export function handleMineSentenceDigit(
const rangeStart = Math.min(...timings.map((t) => t.startTime));
const rangeEnd = Math.max(...timings.map((t) => t.endTime));
const sentence = blocks.join(' ');
const secondarySubText = getSecondarySubTextForMinedBlocks(
entries,
deps.getCurrentSecondarySubText,
);
const cardsToMine = 1;
deps.ankiIntegration
.createSentenceCard(sentence, rangeStart, rangeEnd, deps.getCurrentSecondarySubText())
.createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText)
.then((created) => {
if (created) {
deps.onCardsMined?.(cardsToMine);
+1 -1
View File
@@ -843,7 +843,7 @@ export function createStatsApp(
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
const mediaGen = new MediaGenerator();
const audioPadding = ankiConfig.media?.audioPadding ?? 0.5;
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
const startSec = startMs / 1000;
+90 -9
View File
@@ -464,6 +464,7 @@ import {
composeStartupLifecycleHandlers,
} from './main/runtime/composers';
import { createOverlayWindowRuntimeHandlers } from './main/runtime/overlay-window-runtime-handlers';
import { tryBeginVisibleOverlayNumericSelection } from './main/runtime/overlay-numeric-selection';
import { createStartupBootstrapRuntimeDeps } from './main/startup';
import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
import {
@@ -547,7 +548,12 @@ import {
createCreateJellyfinSetupWindowHandler,
} from './main/runtime/setup-window-factory';
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
import { isYoutubePlaybackActive } from './main/runtime/youtube-playback';
import {
isSameYoutubeMediaPath,
isYoutubeMediaPath,
isYoutubePlaybackActive,
shouldUseCachedYoutubeParsedCues,
} from './main/runtime/youtube-playback';
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload';
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
@@ -988,8 +994,8 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
refreshCurrentSubtitle: (text: string) => {
subtitleProcessingController.refreshCurrentSubtitle(text);
},
refreshSubtitleSidebarSource: async (sourcePath: string) => {
await subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath);
refreshSubtitleSidebarSource: async (sourcePath: string, mediaPath?: string) => {
await subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath, mediaPath);
},
startTokenizationWarmups: async () => {
await startTokenizationWarmups();
@@ -1076,9 +1082,18 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
},
showMpvOsd: (text: string) => showMpvOsd(text),
reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message),
notifyPrimarySubtitleLoaded: () =>
youtubePrimarySubtitleNotificationRuntime.markCurrentMediaPrimarySubtitleLoaded(),
warn: (message: string) => logger.warn(message),
log: (message: string) => logger.info(message),
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
createSubtitleTempDir: () =>
fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-youtube-subtitles-')),
cleanupSubtitleTempDirs: (dirs) => {
for (const dir of dirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
},
});
const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => {
@@ -1545,6 +1560,20 @@ const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNo
notifyFailure: (message) => reportYoutubeSubtitleFailure(message),
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
clearSchedule: clearYoutubePrimarySubtitleNotificationTimer,
getCurrentSubtitleState: async () => {
const client = appState.mpvClient;
if (!client?.connected) {
return null;
}
const [sid, trackList] = await Promise.all([
client.requestProperty('sid').catch(() => null),
client.requestProperty('track-list').catch(() => null),
]);
return {
sid,
trackList: Array.isArray(trackList) ? trackList : null,
};
},
});
function isYoutubePlaybackActiveNow(): boolean {
@@ -1745,6 +1774,9 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
appState.activeParsedSubtitleCues = cues ?? [];
appState.activeParsedSubtitleSource = sourceKey;
if (!cues?.length) {
appState.activeParsedSubtitleMediaPath = null;
}
const mediaPath = getCurrentAutoplayMediaPath();
if (mediaPath && cues?.length) {
void primeAutoplaySubtitleFromParsedCues(mediaPath, cues).catch((error) => {
@@ -1763,11 +1795,15 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
});
async function refreshSubtitleSidebarFromSource(sourcePath: string): Promise<void> {
async function refreshSubtitleSidebarFromSource(
sourcePath: string,
mediaPath?: string,
): Promise<void> {
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
if (!normalizedSourcePath) {
return;
}
appState.activeParsedSubtitleMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
await subtitlePrefetchInitController.initSubtitlePrefetch(
normalizedSourcePath,
lastObservedTimePos,
@@ -1778,6 +1814,7 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
createRefreshSubtitlePrefetchFromActiveTrackHandler({
getMpvClient: () => appState.mpvClient,
getLastObservedTimePos: () => lastObservedTimePos,
shouldKeepExistingCuesOnMissingSource: (videoPath) => isYoutubeMediaPath(videoPath),
subtitlePrefetchInitController,
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
});
@@ -1792,8 +1829,8 @@ function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
const subtitlePrefetchRuntime = {
cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
refreshSubtitleSidebarFromSource: (sourcePath: string) =>
refreshSubtitleSidebarFromSource(sourcePath),
refreshSubtitleSidebarFromSource: (sourcePath: string, mediaPath?: string) =>
refreshSubtitleSidebarFromSource(sourcePath, mediaPath),
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs),
clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(),
@@ -3632,6 +3669,7 @@ const {
appState.yomitanSettingsWindow = null;
},
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
stopDiscordPresenceService: () => {
void appState.discordPresenceService?.stop();
appState.discordPresenceService = null;
@@ -4271,6 +4309,10 @@ const {
updateCurrentMediaPath: (path) => {
const normalizedPath = path.trim();
const previousPath = appState.currentMediaPath?.trim() || null;
const preserveParsedSubtitleCues = isSameYoutubeMediaPath(
normalizedPath,
appState.activeParsedSubtitleMediaPath,
);
if ((normalizedPath || null) !== previousPath) {
const resetSubtitlePayload = { text: '', tokens: null };
const frequencyDictionary = getResolvedConfig().subtitleStyle.frequencyDictionary;
@@ -4284,8 +4326,11 @@ const {
appState.currentSubText = '';
appState.currentSubAssText = '';
appState.currentSubtitleData = null;
appState.activeParsedSubtitleCues = [];
appState.activeParsedSubtitleSource = null;
if (!preserveParsedSubtitleCues) {
appState.activeParsedSubtitleCues = [];
appState.activeParsedSubtitleSource = null;
appState.activeParsedSubtitleMediaPath = null;
}
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
@@ -4295,7 +4340,9 @@ const {
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
startupOsdSequencer.reset();
subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh();
subtitlePrefetchRuntime.cancelPendingInit();
if (!preserveParsedSubtitleCues) {
subtitlePrefetchRuntime.cancelPendingInit();
}
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
if (path) {
ensureImmersionTrackerStarted();
@@ -4844,6 +4891,20 @@ const {
numericSessions: {
onMultiCopyDigit: (count) => handleMultiCopyDigit(count),
onMineSentenceDigit: (count) => handleMineSentenceDigit(count),
tryBeginMultiCopyOverlaySelection: (timeoutMs) =>
tryBeginVisibleOverlayNumericSelection({
actionId: 'copySubtitleMultiple',
timeoutMs,
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
}),
tryBeginMineSentenceOverlaySelection: (timeoutMs) =>
tryBeginVisibleOverlayNumericSelection({
actionId: 'mineSentenceMultiple',
timeoutMs,
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
}),
},
overlayShortcutsRuntimeMainDeps: {
overlayShortcutsRuntime,
@@ -5555,6 +5616,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
config,
};
}
if (
shouldUseCachedYoutubeParsedCues({
videoPath,
cachedMediaPath: appState.activeParsedSubtitleMediaPath,
cachedCueCount: appState.activeParsedSubtitleCues.length,
})
) {
return {
cues: appState.activeParsedSubtitleCues,
currentTimeSec,
currentSubtitle,
config,
};
}
const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({
currentExternalFilenameRaw,
@@ -5586,6 +5661,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
const cues = parseSubtitleCues(content, resolvedSource.path);
appState.activeParsedSubtitleCues = cues;
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
appState.activeParsedSubtitleMediaPath = videoPath || null;
return {
cues,
currentTimeSec,
@@ -5793,6 +5869,11 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
startBackgroundWarmups: () => startBackgroundWarmups(),
logInfo: (message: string) => logger.info(message),
},
ensureTrayForCommand: (args) => {
if (args.background || args.managedPlayback) {
ensureTray();
}
},
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
},
+20 -1
View File
@@ -21,7 +21,7 @@ test('manual watched session action starts immersion tracker before marking watc
);
});
test('media path changes clear rendered subtitle state', () => {
test('media path changes clear rendered subtitle state without clearing same-youtube parsed cues', () => {
const source = readMainSource();
const actionBlock = source.match(
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);/,
@@ -31,8 +31,11 @@ test('media path changes clear rendered subtitle state', () => {
assert.match(actionBlock, /appState\.currentSubText = '';/);
assert.match(actionBlock, /appState\.currentSubAssText = '';/);
assert.match(actionBlock, /appState\.currentSubtitleData = null;/);
assert.match(actionBlock, /isSameYoutubeMediaPath\(/);
assert.match(actionBlock, /if \(!preserveParsedSubtitleCues\)/);
assert.match(actionBlock, /appState\.activeParsedSubtitleCues = \[\];/);
assert.match(actionBlock, /appState\.activeParsedSubtitleSource = null;/);
assert.match(actionBlock, /appState\.activeParsedSubtitleMediaPath = null;/);
assert.match(actionBlock, /lastObservedTimePos = 0;/);
assert.match(actionBlock, /broadcastToOverlayWindows\('subtitle:set',/);
assert.match(actionBlock, /subtitleWsService\.broadcast\(/);
@@ -52,3 +55,19 @@ test('main process uses one shared mpv plugin runtime config helper', () => {
0,
);
});
test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active-source parsing', () => {
const source = readMainSource();
const snapshotBlock = source.match(
/getSubtitleSidebarSnapshot:\s*async\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler)/,
)?.groups?.body;
assert.ok(snapshotBlock);
assert.match(snapshotBlock, /shouldUseCachedYoutubeParsedCues\(/);
assert.match(snapshotBlock, /cachedMediaPath:\s*appState\.activeParsedSubtitleMediaPath/);
assert.match(snapshotBlock, /cachedCueCount:\s*appState\.activeParsedSubtitleCues\.length/);
assert.ok(
snapshotBlock.indexOf('shouldUseCachedYoutubeParsedCues(') <
snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'),
);
});
@@ -40,15 +40,17 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
cleanup();
assert.equal(calls.length, 30);
assert.equal(calls.length, 31);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
});
@@ -28,6 +28,7 @@ export function createOnWillQuitCleanupHandler(deps: {
destroyYomitanSettingsWindow: () => void;
clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void;
cleanupYoutubeSubtitleTempDirs: () => void;
stopDiscordPresenceService: () => void;
}) {
return (): void => {
@@ -60,6 +61,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.destroyYomitanSettingsWindow();
deps.clearYomitanSettingsWindow();
deps.stopJellyfinRemoteSession();
deps.cleanupYoutubeSubtitleTempDirs();
deps.stopDiscordPresenceService();
};
}
@@ -69,6 +69,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
@@ -89,6 +90,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('destroy-first-run-window'));
assert.ok(calls.includes('destroy-yomitan-settings-window'));
assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.includes('stop-discord-presence'));
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
@@ -142,6 +144,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
stopDiscordPresenceService: () => {},
});
@@ -190,6 +193,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
stopDiscordPresenceService: () => {},
});
@@ -57,6 +57,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void;
cleanupYoutubeSubtitleTempDirs: () => void;
stopDiscordPresenceService: () => void;
}) {
return () => ({
@@ -139,6 +140,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
},
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
});
}
@@ -83,3 +83,39 @@ test('cli command runtime handler skips generic overlay prerequisites for youtub
assert.deepEqual(calls, ['context', 'cli:initial:ctx']);
});
test('cli command runtime handler ensures tray for managed playback commands', () => {
const calls: string[] = [];
const handler = createCliCommandRuntimeHandler({
handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => false,
setTexthookerOnlyMode: () => calls.push('set-mode'),
commandNeedsOverlayStartupPrereqs: () => false,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
startBackgroundWarmups: () => calls.push('warmups'),
logInfo: (message) => calls.push(`log:${message}`),
},
ensureTrayForCommand: (args) => {
if (args.managedPlayback) {
calls.push('ensure-tray');
}
},
createCliCommandContext: () => {
calls.push('context');
return { id: 'ctx' };
},
handleCliCommandRuntimeServiceWithContext: (_args, source, context) => {
calls.push(`cli:${source}:${context.id}`);
},
});
handler(
{
managedPlayback: true,
youtubePlay: 'https://youtube.com/watch?v=abc',
} as never,
'second-instance',
);
assert.deepEqual(calls, ['ensure-tray', 'context', 'cli:second-instance:ctx']);
});
@@ -8,6 +8,7 @@ type HandleTexthookerOnlyModeTransitionMainDeps = Parameters<
export function createCliCommandRuntimeHandler<TCliContext>(deps: {
handleTexthookerOnlyModeTransitionMainDeps: HandleTexthookerOnlyModeTransitionMainDeps;
ensureTrayForCommand?: (args: CliArgs, source: CliCommandSource) => void;
createCliCommandContext: () => TCliContext;
handleCliCommandRuntimeServiceWithContext: (
args: CliArgs,
@@ -29,6 +30,7 @@ export function createCliCommandRuntimeHandler<TCliContext>(deps: {
) {
deps.handleTexthookerOnlyModeTransitionMainDeps.ensureOverlayStartupPrereqs();
}
deps.ensureTrayForCommand?.(args, source);
const cliContext = deps.createCliCommandContext();
deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
};
@@ -48,6 +48,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: async () => {},
cleanupYoutubeSubtitleTempDirs: () => {},
stopDiscordPresenceService: () => {},
},
shouldRestoreWindowsOnActivateMainDeps: {
@@ -9,6 +9,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
overlayRuntimeInitialized: true,
mpvClient: {
connected: true,
currentSecondarySubText: 'secondary',
currentTimePos: 12.25,
requestProperty: async () => 18.75,
},
@@ -20,7 +21,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
},
subtitleTimingTracker: {
recordSubtitle: (text: string) => calls.push(`timing:${text}`),
recordSubtitle: (text: string, _start: number, _end: number, secondaryText?: string) =>
calls.push(`timing:${text}:${secondaryText ?? ''}`),
},
currentSubText: '',
currentSubAssText: '',
@@ -113,6 +115,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('remote-stopped'));
assert.ok(calls.includes('sync-overlay-mpv-sub'));
assert.ok(calls.includes('anilist-post-watch'));
assert.ok(calls.includes('timing:y:secondary'));
assert.ok(calls.includes('ensure-immersion'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('autoplay:/tmp/video'));
+7 -2
View File
@@ -32,7 +32,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
recordPauseState?: (paused: boolean) => void;
} | null;
subtitleTimingTracker: {
recordSubtitle?: (text: string, start: number, end: number) => void;
recordSubtitle?: (text: string, start: number, end: number, secondaryText?: string) => void;
} | null;
currentMediaPath?: string | null;
currentSubText: string;
@@ -132,7 +132,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
},
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
recordSubtitleTiming: (text: string, start: number, end: number) =>
deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end),
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
text,
start,
end,
deps.appState.mpvClient?.currentSecondarySubText || undefined,
),
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
deps.maybeRunAnilistPostWatchUpdate(options),
logSubtitleTimingError: (message: string, error: unknown) =>
@@ -33,3 +33,28 @@ test('numeric shortcut session runtime handlers compose cancel/start handlers',
'mine-sentence:digit:3',
]);
});
test('numeric shortcut session runtime handlers prefer overlay digit selection when available', () => {
const calls: string[] = [];
const createSession = (name: string) => ({
start: () => calls.push(`${name}:start`),
cancel: () => calls.push(`${name}:cancel`),
});
const runtime = createNumericShortcutSessionRuntimeHandlers({
multiCopySession: createSession('multi-copy'),
mineSentenceSession: createSession('mine-sentence'),
onMultiCopyDigit: () => calls.push('multi-copy:digit'),
onMineSentenceDigit: () => calls.push('mine-sentence:digit'),
tryBeginMultiCopyOverlaySelection: (timeoutMs) => {
calls.push(`multi-copy:overlay:${timeoutMs}`);
return true;
},
tryBeginMineSentenceOverlaySelection: () => false,
});
runtime.startPendingMultiCopy(500);
runtime.startPendingMineSentenceMultiple(700);
assert.deepEqual(calls, ['multi-copy:overlay:500', 'mine-sentence:start']);
});
@@ -16,6 +16,8 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: {
mineSentenceSession: CancelNumericShortcutSessionMainDeps['session'];
onMultiCopyDigit: (count: number) => void;
onMineSentenceDigit: (count: number) => void;
tryBeginMultiCopyOverlaySelection?: (timeoutMs: number) => boolean;
tryBeginMineSentenceOverlaySelection?: (timeoutMs: number) => boolean;
}) {
const cancelPendingMultiCopyMainDeps = createBuildCancelNumericShortcutSessionMainDepsHandler({
session: deps.multiCopySession,
@@ -61,9 +63,14 @@ export function createNumericShortcutSessionRuntimeHandlers(deps: {
return {
cancelPendingMultiCopy: () => cancelPendingMultiCopyHandler(),
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopyHandler(timeoutMs),
startPendingMultiCopy: (timeoutMs: number) => {
if (deps.tryBeginMultiCopyOverlaySelection?.(timeoutMs)) return;
startPendingMultiCopyHandler(timeoutMs);
},
cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultipleHandler(),
startPendingMineSentenceMultiple: (timeoutMs: number) =>
startPendingMineSentenceMultipleHandler(timeoutMs),
startPendingMineSentenceMultiple: (timeoutMs: number) => {
if (deps.tryBeginMineSentenceOverlaySelection?.(timeoutMs)) return;
startPendingMineSentenceMultipleHandler(timeoutMs);
},
};
}
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { tryBeginVisibleOverlayNumericSelection } from './overlay-numeric-selection';
function createWindowStub(
options: {
destroyed?: boolean;
visible?: boolean;
focused?: boolean;
webContentsFocused?: boolean;
} = {},
) {
const calls: string[] = [];
return {
calls,
window: {
isDestroyed: () => options.destroyed === true,
isVisible: () => options.visible !== false,
isFocused: () => options.focused === true,
setIgnoreMouseEvents: (ignore: boolean) => {
calls.push(`mouse:${ignore}`);
},
focus: () => {
calls.push('focus');
},
webContents: {
isFocused: () => options.webContentsFocused === true,
focus: () => {
calls.push('web-focus');
},
send: (channel: string, payload: unknown) => {
calls.push(`send:${channel}:${JSON.stringify(payload)}`);
},
},
},
};
}
test('tryBeginVisibleOverlayNumericSelection focuses visible overlay and sends selector event', () => {
const { window, calls } = createWindowStub();
const handled = tryBeginVisibleOverlayNumericSelection({
actionId: 'copySubtitleMultiple',
timeoutMs: 1234,
getMainWindow: () => window,
getVisibleOverlayVisible: () => true,
});
assert.equal(handled, true);
assert.deepEqual(calls, [
'mouse:false',
'focus',
'web-focus',
`send:${IPC_CHANNELS.event.sessionNumericSelectionStart}:{"actionId":"copySubtitleMultiple","timeoutMs":1234}`,
]);
});
test('tryBeginVisibleOverlayNumericSelection skips hidden visible overlay', () => {
const { window, calls } = createWindowStub({ visible: false });
const handled = tryBeginVisibleOverlayNumericSelection({
actionId: 'mineSentenceMultiple',
timeoutMs: 3000,
getMainWindow: () => window,
getVisibleOverlayVisible: () => true,
});
assert.equal(handled, false);
assert.deepEqual(calls, []);
});
@@ -0,0 +1,47 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { SessionNumericSelectionStartPayload } from '../../types/runtime';
type OverlayNumericSelectionWindow = {
isDestroyed: () => boolean;
isVisible: () => boolean;
isFocused?: () => boolean;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
focus: () => void;
webContents: {
isFocused?: () => boolean;
focus: () => void;
send: (channel: string, payload: SessionNumericSelectionStartPayload) => void;
};
};
export function tryBeginVisibleOverlayNumericSelection(options: {
actionId: SessionNumericSelectionStartPayload['actionId'];
timeoutMs: number;
getMainWindow: () => OverlayNumericSelectionWindow | null;
getVisibleOverlayVisible: () => boolean;
}): boolean {
if (!options.getVisibleOverlayVisible()) {
return false;
}
const mainWindow = options.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return false;
}
mainWindow.setIgnoreMouseEvents(false);
if (typeof mainWindow.isFocused !== 'function' || !mainWindow.isFocused()) {
mainWindow.focus();
}
if (
typeof mainWindow.webContents.isFocused !== 'function' ||
!mainWindow.webContents.isFocused()
) {
mainWindow.webContents.focus();
}
mainWindow.webContents.send(IPC_CHANNELS.event.sessionNumericSelectionStart, {
actionId: options.actionId,
timeoutMs: options.timeoutMs,
});
return true;
}
@@ -1,6 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createResolveActiveSubtitleSidebarSourceHandler } from './subtitle-prefetch-runtime';
import {
createRefreshSubtitlePrefetchFromActiveTrackHandler,
createResolveActiveSubtitleSidebarSourceHandler,
} from './subtitle-prefetch-runtime';
test('subtitle prefetch runtime resolves direct external subtitle sources first', async () => {
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
@@ -57,3 +60,43 @@ test('subtitle prefetch runtime extracts internal subtitle tracks into a stable
cleanup: resolved?.cleanup,
});
});
test('subtitle prefetch runtime preserves parsed cues when YouTube active track source is unresolved', async () => {
const calls: string[] = [];
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'path') return 'https://www.youtube.com/watch?v=video123';
if (name === 'track-list') {
return [
{
type: 'sub',
id: 4,
lang: 'ja',
title: 'Japanese',
external: true,
},
];
}
if (name === 'sid') return 4;
return null;
},
}),
getLastObservedTimePos: () => 12,
subtitlePrefetchInitController: {
cancelPendingInit: () => {
calls.push('cancel');
},
initSubtitlePrefetch: async () => {
calls.push('init');
},
},
resolveActiveSubtitleSidebarSource: async () => null,
shouldKeepExistingCuesOnMissingSource: (videoPath) => videoPath.includes('youtube.com'),
});
await refresh();
assert.deepEqual(calls, []);
});
@@ -126,6 +126,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
requestProperty: (name: string) => Promise<unknown>;
} | null;
getLastObservedTimePos: () => number;
shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean;
subtitlePrefetchInitController: SubtitlePrefetchInitController;
resolveActiveSubtitleSidebarSource: (
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
@@ -160,6 +161,9 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
videoPath,
});
if (!resolvedSource) {
if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) {
return;
}
deps.subtitlePrefetchInitController.cancelPendingInit();
return;
}
+49
View File
@@ -104,6 +104,55 @@ test('ensure tray creates new tray and binds click handler', () => {
assert.ok(calls.includes('bind-click'));
});
test('ensure tray logs Linux tray registration failures without crashing startup', () => {
const calls: string[] = [];
let trayRef: unknown = null;
const ensureTray = createEnsureTrayHandler({
getTray: () => null,
setTray: (tray) => {
trayRef = tray;
calls.push('set-tray');
},
buildTrayMenu: () => ({ id: 'menu' }),
resolveTrayIconPath: () => '/tmp/icon.png',
createImageFromPath: () =>
({
isEmpty: () => false,
resize: () => ({
isEmpty: () => false,
resize: () => {
throw new Error('unexpected');
},
setTemplateImage: () => {},
}),
setTemplateImage: () => {},
}) as never,
createEmptyImage: () =>
({
isEmpty: () => true,
resize: () => {
throw new Error('unexpected');
},
setTemplateImage: () => {},
}) as never,
createTray: () => {
throw new Error('StatusNotifier watcher unavailable');
},
trayTooltip: 'SubMiner',
platform: 'linux',
logWarn: (message) => calls.push(`warn:${message}`),
ensureOverlayVisibleFromTrayClick: () => calls.push('show-overlay'),
});
ensureTray();
assert.equal(trayRef, null);
assert.deepEqual(calls, [
'warn:Unable to create Linux tray icon. Ensure your desktop has a StatusNotifier/AppIndicator tray host. StatusNotifier watcher unavailable',
]);
});
test('destroy tray handler destroys active tray and clears ref', () => {
const calls: string[] = [];
let tray: { destroy: () => void } | null = {
+14 -1
View File
@@ -48,7 +48,20 @@ export function createEnsureTrayHandler(deps: {
trayIcon = trayIcon.resize({ width: 20, height: 20 });
}
const tray = deps.createTray(trayIcon);
let tray: TrayLike;
try {
tray = deps.createTray(trayIcon);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
if (deps.platform === 'linux') {
deps.logWarn(
`Unable to create Linux tray icon. Ensure your desktop has a StatusNotifier/AppIndicator tray host. ${reason}`,
);
} else {
deps.logWarn(`Unable to create tray icon. ${reason}`);
}
return;
}
tray.setToolTip(deps.trayTooltip);
tray.setContextMenu(deps.buildTrayMenu());
tray.on('click', () => {
+517 -22
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { createYoutubeFlowRuntime } from './youtube-flow';
import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types';
@@ -306,6 +307,7 @@ test('youtube flow reports probe failure through the configured reporter in manu
test('youtube flow does not report failure when subtitle track binds before cue text appears', async () => {
const failures: string[] = [];
const loadedSignals: string[] = [];
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
@@ -358,6 +360,9 @@ test('youtube flow does not report failure when subtitle track binds before cue
reportSubtitleFailure: (message) => {
failures.push(message);
},
notifyPrimarySubtitleLoaded: () => {
loadedSignals.push('loaded');
},
warn: (message) => {
throw new Error(message);
},
@@ -368,6 +373,7 @@ test('youtube flow does not report failure when subtitle track binds before cue
await runtime.openManualPicker({ url: 'https://example.com' });
assert.deepEqual(failures, []);
assert.deepEqual(loadedSignals, ['loaded']);
});
test('youtube flow does not fail when mpv reports sub-text as unavailable after track bind', async () => {
@@ -781,11 +787,13 @@ test('youtube flow leaves non-authoritative youtube subtitle tracks untouched af
);
});
test('youtube flow reuses existing manual youtube subtitle tracks when both requested languages already exist', async () => {
test('youtube flow injects downloaded primary while reusing existing manual secondary tracks', async () => {
const commands: Array<Array<string | number>> = [];
let selectedPrimarySid: number | null = null;
let selectedSecondarySid: number | null = null;
let downloadedPrimaryAdded = false;
const refreshedSidebarSources: string[] = [];
const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt';
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
@@ -813,7 +821,7 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
},
acquireYoutubeSubtitleTrack: async ({ track }) => {
if (track.language === 'ja') {
return { path: '/tmp/manual-ja.ja.srt' };
return { path: downloadedPrimaryPath };
}
throw new Error('should not download secondary track when manual english already exists');
},
@@ -832,6 +840,13 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
resumeMpv: () => {},
sendMpvCommand: (command) => {
commands.push(command);
if (
command[0] === 'sub-add' &&
command[1] === downloadedPrimaryPath &&
command[2] === 'select'
) {
downloadedPrimaryAdded = true;
}
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
selectedPrimarySid = command[2];
}
@@ -853,7 +868,7 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
if (name === 'secondary-sid') {
return selectedSecondarySid;
}
return [
const tracks: Array<Record<string, unknown>> = [
{
type: 'sub',
id: 1,
@@ -887,6 +902,17 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
'external-filename': null,
},
];
if (downloadedPrimaryAdded) {
tracks.push({
type: 'sub',
id: 9,
lang: 'ja',
title: path.basename(downloadedPrimaryPath),
external: true,
'external-filename': downloadedPrimaryPath,
});
}
return tracks;
},
refreshCurrentSubtitle: () => {},
refreshSubtitleSidebarSource: async (sourcePath) => {
@@ -912,24 +938,451 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
await runtime.openManualPicker({ url: 'https://example.com' });
assert.equal(selectedPrimarySid, 2);
assert.equal(selectedPrimarySid, 9);
assert.equal(selectedSecondarySid, 1);
assert.equal(
commands.some((command) => command[0] === 'sub-add'),
false,
assert.ok(
commands.some(
(command) =>
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
),
);
assert.deepEqual(refreshedSidebarSources, ['/tmp/manual-ja.ja.srt']);
assert.deepEqual(refreshedSidebarSources, [downloadedPrimaryPath]);
assert.equal(
commands.some((command) => command[0] === 'sub-remove'),
false,
);
});
test('youtube flow waits for manual youtube tracks to appear before falling back to injected copies', async () => {
test('youtube flow injects downloaded primary subtitles instead of reusing streamed youtube tracks', async () => {
const commands: Array<Array<string | number>> = [];
const refreshedSidebarSources: string[] = [];
let selectedPrimarySid: number | null = null;
let downloadedPrimaryAdded = false;
const downloadedPrimaryPath = '/tmp/subminer-youtube-subtitles-abc/manual-ja.ja.vtt';
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [
{
...primaryTrack,
id: 'manual:ja',
sourceLanguage: 'ja',
kind: 'manual',
title: 'Japanese',
},
],
}),
acquireYoutubeSubtitleTracks: async () => {
throw new Error('single primary selection should not batch download');
},
acquireYoutubeSubtitleTrack: async ({ track }) => {
assert.equal(track.id, 'manual:ja');
return { path: downloadedPrimaryPath };
},
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: 'manual:ja',
secondaryTrackId: null,
});
});
return true;
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: (command) => {
commands.push(command);
if (
command[0] === 'sub-add' &&
command[1] === downloadedPrimaryPath &&
command[2] === 'select'
) {
downloadedPrimaryAdded = true;
}
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
selectedPrimarySid = command[2];
}
},
requestMpvProperty: async (name) => {
if (name === 'sub-text') {
return '字幕です';
}
if (name === 'sid') {
return selectedPrimarySid;
}
return downloadedPrimaryAdded
? [
{
type: 'sub',
id: 2,
lang: 'ja',
title: 'Japanese',
external: true,
'external-filename': '/tmp/mpv-ytdl-track-ja.vtt',
},
{
type: 'sub',
id: 9,
lang: 'ja',
title: path.basename(downloadedPrimaryPath),
external: true,
'external-filename': downloadedPrimaryPath,
},
]
: [
{
type: 'sub',
id: 2,
lang: 'ja',
title: 'Japanese',
external: true,
'external-filename': '/tmp/mpv-ytdl-track-ja.vtt',
},
];
},
refreshCurrentSubtitle: () => {},
refreshSubtitleSidebarSource: async (sourcePath) => {
refreshedSidebarSources.push(sourcePath);
},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: (message) => {
throw new Error(message);
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
assert.equal(selectedPrimarySid, 9);
assert.deepEqual(refreshedSidebarSources, [downloadedPrimaryPath]);
assert.ok(
commands.some(
(command) =>
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
),
);
});
test('youtube flow confirms primary subtitle load before sidebar and tokenization waits', async () => {
const events: string[] = [];
let selectedPrimarySid: number | null = null;
let downloadedPrimaryAdded = false;
const downloadedPrimaryPath = '/tmp/subminer-youtube-subtitles-abc/auto-ja-orig.vtt';
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack],
}),
acquireYoutubeSubtitleTracks: async () => {
throw new Error('single primary selection should not batch download');
},
acquireYoutubeSubtitleTrack: async () => ({ path: downloadedPrimaryPath }),
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: null,
});
});
return true;
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: (command) => {
if (
command[0] === 'sub-add' &&
command[1] === downloadedPrimaryPath &&
command[2] === 'select'
) {
downloadedPrimaryAdded = true;
}
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
selectedPrimarySid = command[2];
}
},
requestMpvProperty: async (name) => {
if (name === 'sub-text') {
return '字幕です';
}
if (name === 'sid') {
return selectedPrimarySid;
}
return downloadedPrimaryAdded
? [
{
type: 'sub',
id: 9,
lang: 'ja-orig',
title: path.basename(downloadedPrimaryPath),
external: true,
'external-filename': downloadedPrimaryPath,
},
]
: [];
},
refreshCurrentSubtitle: () => {},
refreshSubtitleSidebarSource: async () => {
events.push('sidebar');
assert.ok(
events.includes('notify'),
'primary load should be confirmed before sidebar parsing can delay',
);
},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {
events.push('tokenization');
assert.ok(
events.includes('notify'),
'primary load should be confirmed before tokenization waits can delay',
);
},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: (message) => {
throw new Error(message);
},
notifyPrimarySubtitleLoaded: () => {
events.push('notify');
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
assert.deepEqual(events, ['notify', 'sidebar', 'tokenization']);
});
test('youtube flow downloads subtitles into temporary dirs and exposes cleanup', async () => {
const outputDirs: string[] = [];
const cleanupCalls: string[][] = [];
let tempDirIndex = 0;
let selectedPrimarySid: number | null = null;
let addedSubtitlePath: string | null = null;
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack],
}),
acquireYoutubeSubtitleTracks: async () => {
throw new Error('single primary selection should not batch download');
},
acquireYoutubeSubtitleTrack: async ({ outputDir }) => {
outputDirs.push(outputDir);
return { path: path.join(outputDir, 'auto-ja-orig.vtt') };
},
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: null,
});
});
return true;
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: (command) => {
if (command[0] === 'sub-add' && typeof command[1] === 'string') {
addedSubtitlePath = command[1];
}
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
selectedPrimarySid = command[2];
}
},
requestMpvProperty: async (name) => {
if (name === 'sub-text') {
return '字幕です';
}
if (name === 'sid') {
return selectedPrimarySid;
}
return addedSubtitlePath
? [
{
type: 'sub',
id: 10 + tempDirIndex,
lang: 'ja-orig',
title: path.basename(addedSubtitlePath),
external: true,
'external-filename': addedSubtitlePath,
},
]
: [];
},
refreshCurrentSubtitle: () => {},
refreshSubtitleSidebarSource: async () => {},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: (message) => {
throw new Error(message);
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp/unused-youtube-cache',
createSubtitleTempDir: async () => {
tempDirIndex += 1;
return `/tmp/subminer-youtube-subtitles-${tempDirIndex}`;
},
cleanupSubtitleTempDirs: (dirs) => {
cleanupCalls.push([...dirs]);
},
});
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
addedSubtitlePath = null;
selectedPrimarySid = null;
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
runtime.cleanupSubtitleTempDirs();
runtime.cleanupSubtitleTempDirs();
assert.deepEqual(outputDirs, [
'/tmp/subminer-youtube-subtitles-1',
'/tmp/subminer-youtube-subtitles-2',
]);
assert.deepEqual(cleanupCalls, [
['/tmp/subminer-youtube-subtitles-1'],
['/tmp/subminer-youtube-subtitles-2'],
]);
});
test('youtube flow falls back to configured output dir when subtitle temp dir creation fails', async () => {
const outputDirs: string[] = [];
const warnings: string[] = [];
let selectedPrimarySid: number | null = null;
let addedSubtitlePath: string | null = null;
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack],
}),
acquireYoutubeSubtitleTracks: async () => {
throw new Error('single primary selection should not batch download');
},
acquireYoutubeSubtitleTrack: async ({ outputDir }) => {
outputDirs.push(outputDir);
return { path: path.join(outputDir, 'auto-ja-orig.vtt') };
},
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: null,
});
});
return true;
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: (command) => {
if (command[0] === 'sub-add' && typeof command[1] === 'string') {
addedSubtitlePath = command[1];
}
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
selectedPrimarySid = command[2];
}
},
requestMpvProperty: async (name) => {
if (name === 'sub-text') {
return '字幕です';
}
if (name === 'sid') {
return selectedPrimarySid;
}
return addedSubtitlePath
? [
{
type: 'sub',
id: 11,
lang: 'ja-orig',
title: path.basename(addedSubtitlePath),
external: true,
'external-filename': addedSubtitlePath,
},
]
: [];
},
refreshCurrentSubtitle: () => {},
refreshSubtitleSidebarSource: async () => {},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: (message) => {
throw new Error(message);
},
warn: (message) => {
warnings.push(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp/youtube-cache',
createSubtitleTempDir: async () => {
throw new Error('tmp unavailable');
},
cleanupSubtitleTempDirs: () => {},
});
await runtime.openManualPicker({ url: 'https://example.com/watch?v=video123' });
assert.deepEqual(outputDirs, ['/tmp/youtube-cache']);
assert.deepEqual(warnings, [
'Failed to create YouTube subtitle temp dir; using configured output dir: tmp unavailable',
]);
});
test('youtube flow waits for manual secondary tracks while injecting downloaded primary', async () => {
const commands: Array<Array<string | number>> = [];
let selectedPrimarySid: number | null = null;
let selectedSecondarySid: number | null = null;
let trackListReads = 0;
let downloadedPrimaryAdded = false;
const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt';
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
@@ -957,7 +1410,7 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
},
acquireYoutubeSubtitleTrack: async ({ track }) => {
if (track.language === 'ja') {
return { path: '/tmp/manual-ja.ja.srt' };
return { path: downloadedPrimaryPath };
}
throw new Error('should not download secondary track when manual english appears in mpv');
},
@@ -976,6 +1429,13 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
resumeMpv: () => {},
sendMpvCommand: (command) => {
commands.push(command);
if (
command[0] === 'sub-add' &&
command[1] === downloadedPrimaryPath &&
command[2] === 'select'
) {
downloadedPrimaryAdded = true;
}
if (command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number') {
selectedPrimarySid = command[2];
}
@@ -1001,7 +1461,7 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
if (trackListReads === 1) {
return [];
}
return [
const tracks: Array<Record<string, unknown>> = [
{
type: 'sub',
id: 1,
@@ -1035,6 +1495,17 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
'external-filename': null,
},
];
if (downloadedPrimaryAdded) {
tracks.push({
type: 'sub',
id: 9,
lang: 'ja',
title: path.basename(downloadedPrimaryPath),
external: true,
'external-filename': downloadedPrimaryPath,
});
}
return tracks;
},
refreshCurrentSubtitle: () => {},
startTokenizationWarmups: async () => {},
@@ -1057,18 +1528,22 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
await runtime.openManualPicker({ url: 'https://example.com' });
assert.equal(selectedPrimarySid, 2);
assert.equal(selectedPrimarySid, 9);
assert.equal(selectedSecondarySid, 1);
assert.equal(
commands.some((command) => command[0] === 'sub-add'),
false,
assert.ok(
commands.some(
(command) =>
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
),
);
});
test('youtube flow reuses manual youtube tracks even when mpv exposes external filenames', async () => {
test('youtube flow injects downloaded primary even when reusable manual youtube tracks exist', async () => {
const commands: Array<Array<string | number>> = [];
let selectedPrimarySid: number | null = null;
let selectedSecondarySid: number | null = null;
let downloadedPrimaryAdded = false;
const downloadedPrimaryPath = '/tmp/manual-ja.ja.srt';
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
@@ -1098,7 +1573,7 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
},
acquireYoutubeSubtitleTrack: async ({ track }) => {
if (track.id === 'manual:ja') {
return { path: '/tmp/manual-ja.ja.srt' };
return { path: downloadedPrimaryPath };
}
throw new Error(
'should not download secondary track when existing manual english track is reusable',
@@ -1109,6 +1584,13 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
resumeMpv: () => {},
sendMpvCommand: (command) => {
commands.push(command);
if (
command[0] === 'sub-add' &&
command[1] === downloadedPrimaryPath &&
command[2] === 'select'
) {
downloadedPrimaryAdded = true;
}
if (command[0] === 'set_property' && command[1] === 'sid') {
selectedPrimarySid = Number(command[2]);
}
@@ -1118,7 +1600,7 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
},
requestMpvProperty: async (name) => {
if (name === 'track-list') {
return [
const tracks: Array<Record<string, unknown>> = [
{
type: 'sub',
id: 1,
@@ -1144,6 +1626,17 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
'external-filename': '/tmp/mpv-ytdl-track-ja-en.vtt',
},
];
if (downloadedPrimaryAdded) {
tracks.push({
type: 'sub',
id: 9,
lang: 'ja',
title: path.basename(downloadedPrimaryPath),
external: true,
'external-filename': downloadedPrimaryPath,
});
}
return tracks;
}
if (name === 'sid') {
return selectedPrimarySid;
@@ -1181,11 +1674,13 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
mode: 'download',
});
assert.equal(selectedPrimarySid, 2);
assert.equal(selectedPrimarySid, 9);
assert.equal(selectedSecondarySid, 1);
assert.equal(
commands.some((command) => command[0] === 'sub-add'),
false,
assert.ok(
commands.some(
(command) =>
command[0] === 'sub-add' && command[1] === downloadedPrimaryPath && command[2] === 'select',
),
);
});
+62 -15
View File
@@ -32,7 +32,7 @@ type YoutubeFlowDeps = {
sendMpvCommand: (command: Array<string | number>) => void;
requestMpvProperty: (name: string) => Promise<unknown>;
refreshCurrentSubtitle: (text: string) => void;
refreshSubtitleSidebarSource?: (sourcePath: string) => Promise<void>;
refreshSubtitleSidebarSource?: (sourcePath: string, mediaPath?: string) => Promise<void>;
startTokenizationWarmups: () => Promise<void>;
waitForTokenizationReady: () => Promise<void>;
waitForAnkiReady: () => Promise<void>;
@@ -42,9 +42,12 @@ type YoutubeFlowDeps = {
focusOverlayWindow: () => void;
showMpvOsd: (text: string) => void;
reportSubtitleFailure: (message: string) => void;
notifyPrimarySubtitleLoaded?: () => void;
warn: (message: string) => void;
log: (message: string) => void;
getYoutubeOutputDir: () => string;
createSubtitleTempDir?: () => Promise<string>;
cleanupSubtitleTempDirs?: (dirs: string[]) => void;
};
type YoutubeFlowSession = {
@@ -349,7 +352,9 @@ async function injectDownloadedSubtitles(
}
let trackListRaw: unknown = await deps.requestMpvProperty('track-list');
let primaryTrackId: number | null = primarySelection.existingTrackId;
let primaryTrackId: number | null = primarySelection.injectedPath
? null
: primarySelection.existingTrackId;
let secondaryTrackId: number | null = secondarySelection?.existingTrackId ?? null;
for (let attempt = 0; attempt < 12; attempt += 1) {
if (attempt > 0 || primarySelection.injectedPath || secondarySelection?.injectedPath) {
@@ -423,6 +428,53 @@ async function injectDownloadedSubtitles(
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
let activeSession: YoutubeFlowSession | null = null;
const activeSubtitleTempDirs = new Set<string>();
const cleanupSubtitleTempDirs = (): void => {
const dirs = [...activeSubtitleTempDirs];
if (dirs.length === 0) {
return;
}
if (!deps.cleanupSubtitleTempDirs) {
activeSubtitleTempDirs.clear();
return;
}
deps.cleanupSubtitleTempDirs(dirs);
for (const dir of dirs) {
activeSubtitleTempDirs.delete(dir);
}
};
const cleanupSubtitleTempDirsForNextLoad = (): void => {
try {
cleanupSubtitleTempDirs();
} catch (error) {
deps.warn(
`Failed to cleanup YouTube subtitle temp files: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
};
const prepareSubtitleOutputDir = async (fallbackOutputDir: string): Promise<string> => {
if (!deps.createSubtitleTempDir || !deps.cleanupSubtitleTempDirs) {
return fallbackOutputDir;
}
cleanupSubtitleTempDirsForNextLoad();
try {
const tempDir = await deps.createSubtitleTempDir();
activeSubtitleTempDirs.add(tempDir);
return tempDir;
} catch (error) {
deps.warn(
`Failed to create YouTube subtitle temp dir; using configured output dir: ${
error instanceof Error ? error.message : String(error)
}`,
);
return fallbackOutputDir;
}
};
const acquireSelectedTracks = async (input: {
targetUrl: string;
@@ -567,6 +619,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
osdProgress.setMessage('Downloading subtitles...');
}
try {
const outputDir = await prepareSubtitleOutputDir(input.outputDir);
let initialTrackListRaw: unknown = null;
let existingPrimaryTrackId: number | null = null;
let existingSecondaryTrackId: number | null = null;
@@ -602,19 +655,11 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
let primaryInjectedPath: string | null = null;
let secondaryInjectedPath: string | null = null;
if (existingPrimaryTrackId !== null) {
primarySidebarPath = (
await deps.acquireYoutubeSubtitleTrack({
targetUrl: input.url,
outputDir: input.outputDir,
track: input.primaryTrack,
})
).path;
} else if (existingSecondaryTrackId !== null || !input.secondaryTrack) {
if (existingSecondaryTrackId !== null || !input.secondaryTrack) {
primaryInjectedPath = (
await deps.acquireYoutubeSubtitleTrack({
targetUrl: input.url,
outputDir: input.outputDir,
outputDir,
track: input.primaryTrack,
})
).path;
@@ -622,7 +667,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
} else {
const acquired = await acquireSelectedTracks({
targetUrl: input.url,
outputDir: input.outputDir,
outputDir,
primaryTrack: input.primaryTrack,
secondaryTrack: existingSecondaryTrackId === null ? input.secondaryTrack : null,
secondaryFailureLabel: input.secondaryFailureLabel,
@@ -641,7 +686,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
secondaryInjectedPath = (
await deps.acquireYoutubeSubtitleTrack({
targetUrl: input.url,
outputDir: input.outputDir,
outputDir,
track: input.secondaryTrack,
})
).path;
@@ -685,8 +730,9 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
if (!refreshedActiveSubtitle) {
return false;
}
deps.notifyPrimarySubtitleLoaded?.();
try {
await deps.refreshSubtitleSidebarSource?.(primarySidebarPath);
await deps.refreshSubtitleSidebarSource?.(primarySidebarPath, input.url);
} catch (error) {
deps.warn(
`Failed to refresh parsed subtitle cues for sidebar: ${
@@ -877,5 +923,6 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
resolveActivePicker,
cancelActivePicker,
hasActiveSession: () => Boolean(activeSession),
cleanupSubtitleTempDirs,
};
}
+52 -1
View File
@@ -1,6 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isYoutubeMediaPath, isYoutubePlaybackActive } from './youtube-playback';
import {
isSameYoutubeMediaPath,
isYoutubeMediaPath,
isYoutubePlaybackActive,
shouldUseCachedYoutubeParsedCues,
} from './youtube-playback';
test('isYoutubeMediaPath detects youtube watch and short urls', () => {
assert.equal(isYoutubeMediaPath('https://www.youtube.com/watch?v=abc123'), true);
@@ -22,3 +27,49 @@ test('isYoutubePlaybackActive checks both current media and mpv video paths', ()
assert.equal(isYoutubePlaybackActive('https://www.youtube.com/watch?v=abc123', null), true);
assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', '/tmp/video.mkv'), false);
});
test('isSameYoutubeMediaPath matches equivalent youtube urls by video id', () => {
assert.equal(
isSameYoutubeMediaPath('https://www.youtube.com/watch?v=abc123&t=30', 'https://youtu.be/abc123'),
true,
);
assert.equal(
isSameYoutubeMediaPath(
'https://www.youtube.com/embed/abc123',
'https://www.youtube-nocookie.com/embed/abc123',
),
true,
);
assert.equal(
isSameYoutubeMediaPath('https://www.youtube.com/watch?v=abc123', 'https://youtu.be/xyz789'),
false,
);
assert.equal(isSameYoutubeMediaPath('/tmp/video.mkv', 'https://youtu.be/abc123'), false);
});
test('shouldUseCachedYoutubeParsedCues requires cached cues for the same youtube video', () => {
assert.equal(
shouldUseCachedYoutubeParsedCues({
videoPath: 'https://www.youtube.com/watch?v=abc123&t=30',
cachedMediaPath: 'https://youtu.be/abc123',
cachedCueCount: 12,
}),
true,
);
assert.equal(
shouldUseCachedYoutubeParsedCues({
videoPath: 'https://www.youtube.com/watch?v=abc123',
cachedMediaPath: 'https://youtu.be/abc123',
cachedCueCount: 0,
}),
false,
);
assert.equal(
shouldUseCachedYoutubeParsedCues({
videoPath: 'https://www.youtube.com/watch?v=abc123',
cachedMediaPath: 'https://youtu.be/other',
cachedCueCount: 12,
}),
false,
);
});
+53
View File
@@ -10,6 +10,39 @@ function matchesYoutubeHost(hostname: string, expectedHost: string): boolean {
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
}
function extractYoutubeVideoId(mediaPath: string | null | undefined): string | null {
const normalized = trimToNull(mediaPath);
if (!normalized) {
return null;
}
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
return null;
}
const host = parsed.hostname.toLowerCase();
if (matchesYoutubeHost(host, 'youtu.be')) {
return parsed.pathname.replace(/^\/+/, '').split('/')[0]?.trim() || null;
}
if (
!matchesYoutubeHost(host, 'youtube.com') &&
!matchesYoutubeHost(host, 'youtube-nocookie.com')
) {
return null;
}
if (parsed.pathname === '/watch') {
return parsed.searchParams.get('v')?.trim() || null;
}
const pathSegments = parsed.pathname.replace(/^\/+/, '').split('/');
if (pathSegments[0] === 'shorts' || pathSegments[0] === 'embed') {
return pathSegments[1]?.trim() || null;
}
return null;
}
export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean {
const normalized = trimToNull(mediaPath);
if (!normalized) {
@@ -31,6 +64,26 @@ export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolea
);
}
export function isSameYoutubeMediaPath(
left: string | null | undefined,
right: string | null | undefined,
): boolean {
const leftId = extractYoutubeVideoId(left);
const rightId = extractYoutubeVideoId(right);
return Boolean(leftId && rightId && leftId === rightId);
}
export function shouldUseCachedYoutubeParsedCues(input: {
videoPath: string | null | undefined;
cachedMediaPath: string | null | undefined;
cachedCueCount: number;
}): boolean {
return (
input.cachedCueCount > 0 &&
isSameYoutubeMediaPath(input.videoPath, input.cachedMediaPath)
);
}
export function isYoutubePlaybackActive(
currentMediaPath: string | null | undefined,
currentVideoPath: string | null | undefined,
@@ -7,9 +7,9 @@ import {
function createTimerHarness() {
let nextId = 1;
const timers = new Map<number, () => void>();
const timers = new Map<number, () => void | Promise<void>>();
return {
schedule: (fn: () => void): YoutubePrimarySubtitleNotificationTimer => {
schedule: (fn: () => void | Promise<void>): YoutubePrimarySubtitleNotificationTimer => {
const id = nextId++;
timers.set(id, fn);
return { id };
@@ -26,7 +26,14 @@ function createTimerHarness() {
const pending = [...timers.values()];
timers.clear();
for (const fn of pending) {
fn();
void fn();
}
},
runAllAsync: async () => {
const pending = [...timers.values()];
timers.clear();
for (const fn of pending) {
await fn();
}
},
size: () => timers.size,
@@ -195,3 +202,80 @@ test('notifier suppresses timer while app-owned youtube flow is still settling',
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
]);
});
test('notifier suppresses stale delayed failure after primary subtitle load is confirmed', () => {
const notifications: string[] = [];
const timers = createTimerHarness();
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => ['ja'],
notifyFailure: (message) => {
notifications.push(message);
},
schedule: (fn) => timers.schedule(fn),
clearSchedule: (timer) => timers.clear(timer),
});
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=abc');
runtime.handleSubtitleTrackChange(null);
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 2, lang: 'en', title: 'English', external: true },
]);
runtime.markCurrentMediaPrimarySubtitleLoaded();
assert.equal(timers.size(), 0);
timers.runAll();
assert.deepEqual(notifications, []);
});
test('notifier suppresses delayed failure when live mpv state has downloaded primary selected', async () => {
const notifications: string[] = [];
const timers = createTimerHarness();
let liveStateReads = 0;
const runtime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => ['ja'],
notifyFailure: (message) => {
notifications.push(message);
},
schedule: (fn) => timers.schedule(fn),
clearSchedule: (timer) => timers.clear(timer),
getCurrentSubtitleState: async () => {
liveStateReads += 1;
return {
sid: 22,
trackList: [
{
type: 'sub',
id: 1,
lang: 'en',
title: 'English',
external: true,
selected: true,
'main-selection': 1,
},
{
type: 'sub',
id: 22,
lang: 'ja',
title: 'manual-ja.ja.srt',
external: true,
selected: true,
'main-selection': 0,
'external-filename': '/tmp/subminer-youtube-subtitles-aahLWu/manual-ja.ja.srt',
},
],
};
},
});
runtime.handleMediaPathChange('https://www.youtube.com/watch?v=uO2jfacqjYQ');
runtime.handleSubtitleTrackChange(null);
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 1, lang: 'en', title: 'English', external: true, selected: false },
]);
assert.equal(timers.size(), 1);
await timers.runAllAsync();
assert.equal(liveStateReads, 1);
assert.deepEqual(notifications, []);
});
@@ -13,6 +13,11 @@ type SubtitleTrackEntry = {
selected: boolean;
};
type CurrentSubtitleState = {
sid: unknown;
trackList: unknown[] | null;
};
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
@@ -101,8 +106,12 @@ function hasSelectedPrimarySubtitle(
export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
getPrimarySubtitleLanguages: () => string[];
notifyFailure: (message: string) => void;
schedule: (fn: () => void, delayMs: number) => YoutubePrimarySubtitleNotificationTimer;
schedule: (
fn: () => void | Promise<void>,
delayMs: number,
) => YoutubePrimarySubtitleNotificationTimer;
clearSchedule: (timer: YoutubePrimarySubtitleNotificationTimer | null) => void;
getCurrentSubtitleState?: () => CurrentSubtitleState | null | Promise<CurrentSubtitleState | null>;
delayMs?: number;
}) {
const delayMs = deps.delayMs ?? 5000;
@@ -112,13 +121,35 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
let pendingTimer: YoutubePrimarySubtitleNotificationTimer | null = null;
let lastReportedMediaPath: string | null = null;
let appOwnedFlowInFlight = false;
let primarySubtitleLoadedForCurrentMedia = false;
const clearPendingTimer = (): void => {
deps.clearSchedule(pendingTimer);
pendingTimer = null;
};
const maybeReportFailure = (): void => {
const refreshCurrentSubtitleState = async (
preferredLanguages: Set<string>,
): Promise<boolean> => {
const getCurrentSubtitleState = deps.getCurrentSubtitleState;
if (!getCurrentSubtitleState) {
return false;
}
let state: CurrentSubtitleState | null;
try {
state = await getCurrentSubtitleState();
} catch {
state = null;
}
if (!state) {
return false;
}
currentSid = parseTrackId(state.sid);
currentTrackList = Array.isArray(state.trackList) ? state.trackList : null;
return hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages);
};
const maybeReportFailure = async (): Promise<void> => {
const mediaPath = currentMediaPath?.trim() || '';
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
return;
@@ -126,13 +157,30 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
if (lastReportedMediaPath === mediaPath) {
return;
}
if (appOwnedFlowInFlight) {
return;
}
const preferredLanguages = buildPreferredLanguageSet(deps.getPrimarySubtitleLanguages());
if (preferredLanguages.size === 0) {
return;
}
if (primarySubtitleLoadedForCurrentMedia) {
return;
}
if (hasSelectedPrimarySubtitle(currentSid, currentTrackList, preferredLanguages)) {
return;
}
if (deps.getCurrentSubtitleState && (await refreshCurrentSubtitleState(preferredLanguages))) {
clearPendingTimer();
return;
}
if (
currentMediaPath?.trim() !== mediaPath ||
appOwnedFlowInFlight ||
primarySubtitleLoadedForCurrentMedia
) {
return;
}
lastReportedMediaPath = mediaPath;
deps.notifyFailure(
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
@@ -148,9 +196,12 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
return;
}
pendingTimer = deps.schedule(() => {
if (primarySubtitleLoadedForCurrentMedia) {
return;
}
pendingTimer = deps.schedule(async () => {
pendingTimer = null;
maybeReportFailure();
await maybeReportFailure();
}, delayMs);
};
@@ -160,6 +211,7 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
typeof path === 'string' && path.trim().length > 0 ? path.trim() : null;
if (currentMediaPath !== normalizedPath) {
lastReportedMediaPath = null;
primarySubtitleLoadedForCurrentMedia = false;
}
currentMediaPath = normalizedPath;
currentSid = null;
@@ -180,6 +232,14 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
clearPendingTimer();
}
},
markCurrentMediaPrimarySubtitleLoaded: (): void => {
const mediaPath = currentMediaPath?.trim() || '';
if (!mediaPath || !isYoutubeMediaPath(mediaPath)) {
return;
}
primarySubtitleLoadedForCurrentMedia = true;
clearPendingTimer();
},
setAppOwnedFlowInFlight: (inFlight: boolean): void => {
appOwnedFlowInFlight = inFlight;
if (inFlight) {
+2
View File
@@ -163,6 +163,7 @@ export interface AppState {
currentSubtitleData: SubtitleData | null;
activeParsedSubtitleCues: SubtitleCue[];
activeParsedSubtitleSource: string | null;
activeParsedSubtitleMediaPath: string | null;
windowTracker: BaseWindowTracker | null;
subtitlePosition: SubtitlePosition | null;
currentMediaPath: string | null;
@@ -248,6 +249,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
currentSubtitleData: null,
activeParsedSubtitleCues: [],
activeParsedSubtitleSource: null,
activeParsedSubtitleMediaPath: null,
windowTracker: null,
subtitlePosition: null,
currentMediaPath: null,
+140 -1
View File
@@ -1,7 +1,60 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import test from 'node:test';
import { buildAnimatedImageVideoFilter } from './media-generator';
import { buildAnimatedImageVideoFilter, MediaGenerator } from './media-generator';
async function withStubbedFfmpeg(
run: (generator: MediaGenerator, argsPath: string) => Promise<void>,
): Promise<void> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-generator-test-'));
const binDir = path.join(root, 'bin');
const tempDir = path.join(root, 'media');
const argsPath = path.join(root, 'ffmpeg-args.txt');
fs.mkdirSync(binDir, { recursive: true });
const ffmpegPath = path.join(binDir, 'ffmpeg');
fs.writeFileSync(
ffmpegPath,
[
'#!/bin/sh',
'if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then',
' echo " V..... libaom-av1"',
' exit 0',
'fi',
'printf "%s\\n" "$@" > "$SUBMINER_TEST_FFMPEG_ARGS"',
'out=""',
'for arg in "$@"; do out="$arg"; done',
'printf avif > "$out"',
].join('\n'),
'utf8',
);
fs.chmodSync(ffmpegPath, 0o755);
const originalPath = process.env.PATH;
const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS;
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
process.env.SUBMINER_TEST_FFMPEG_ARGS = argsPath;
const generator = new MediaGenerator(tempDir);
try {
await run(generator, argsPath);
} finally {
generator.cleanup();
process.env.PATH = originalPath;
if (originalArgsPath === undefined) {
delete process.env.SUBMINER_TEST_FFMPEG_ARGS;
} else {
process.env.SUBMINER_TEST_FFMPEG_ARGS = originalArgsPath;
}
fs.rmSync(root, { recursive: true, force: true });
}
}
function readFfmpegArgs(argsPath: string): string[] {
return fs.readFileSync(argsPath, 'utf8').trim().split('\n');
}
test('buildAnimatedImageVideoFilter prepends a cloned first frame when lead-in is provided', () => {
assert.equal(
@@ -13,3 +66,89 @@ test('buildAnimatedImageVideoFilter prepends a cloned first frame when lead-in i
'tpad=start_duration=1.25:start_mode=clone,fps=10,scale=w=640:h=-2',
);
});
test('generateAnimatedImage freezes first frame for leading audio padding', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAnimatedImage('/video.mp4', 10, 12, 0.5, {
fps: 10,
maxWidth: 640,
});
const args = readFfmpegArgs(argsPath);
assert.equal(args[args.indexOf('-ss') + 1], '10');
assert.equal(args[args.indexOf('-t') + 1], '2.5');
assert.equal(
args[args.indexOf('-vf') + 1],
'tpad=start_duration=0.5:start_mode=clone,fps=10,scale=w=640:h=-2',
);
});
});
test('generateAnimatedImage defaults to unpadded sentence timing', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAnimatedImage('/video.mp4', 10, 12, undefined, {
fps: 10,
maxWidth: 640,
});
const args = readFfmpegArgs(argsPath);
assert.equal(args[args.indexOf('-ss') + 1], '10');
assert.equal(args[args.indexOf('-t') + 1], '2');
assert.equal(args[args.indexOf('-vf') + 1], 'fps=10,scale=w=640:h=-2');
});
});
test('generateAnimatedImage adds audio lead padding to existing word-audio lead-in', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAnimatedImage('/video.mp4', 10, 12, 0.5, {
fps: 10,
maxWidth: 640,
leadingStillDuration: 1.25,
});
const args = readFfmpegArgs(argsPath);
assert.equal(args[args.indexOf('-ss') + 1], '10');
assert.equal(args[args.indexOf('-t') + 1], '2.5');
assert.equal(
args[args.indexOf('-vf') + 1],
'tpad=start_duration=1.75:start_mode=clone,fps=10,scale=w=640:h=-2',
);
});
});
test('generateAnimatedImage clips leading audio padding at the start of media', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAnimatedImage('/video.mp4', 0.2, 1.2, 0.5, {
fps: 10,
maxWidth: 640,
});
const args = readFfmpegArgs(argsPath);
assert.equal(args[args.indexOf('-ss') + 1], '0.2');
assert.equal(args[args.indexOf('-t') + 1], '1.5');
assert.equal(
args[args.indexOf('-vf') + 1],
'tpad=start_duration=0.2:start_mode=clone,fps=10,scale=w=640:h=-2',
);
});
});
test('generateAudio defaults to unpadded sentence timing', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAudio('/video.mp4', 10, 12);
const args = readFfmpegArgs(argsPath);
assert.equal(args[args.indexOf('-ss') + 1], '10');
assert.equal(args[args.indexOf('-t') + 1], '2');
});
});
test('generateAudio clips leading padding without adding it to trailing duration', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
await generator.generateAudio('/video.mp4', 0.2, 1.2, 0.5);
const args = readFfmpegArgs(argsPath);
assert.equal(args[args.indexOf('-ss') + 1], '0');
assert.equal(args[args.indexOf('-t') + 1], '1.7');
});
});
+11 -7
View File
@@ -158,11 +158,12 @@ export class MediaGenerator {
videoPath: string,
startTime: number,
endTime: number,
padding: number = 0.5,
padding: number = 0,
audioStreamIndex: number | null = null,
): Promise<Buffer> {
const start = Math.max(0, startTime - padding);
const duration = endTime - startTime + 2 * padding;
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
const start = Math.max(0, startTime - safePadding);
const duration = endTime - start + safePadding;
return new Promise((resolve, reject) => {
const outputPath = path.join(this.tempDir, `audio_${Date.now()}.mp3`);
@@ -310,7 +311,7 @@ export class MediaGenerator {
videoPath: string,
startTime: number,
endTime: number,
padding: number = 0.5,
padding: number = 0,
options: {
fps?: number;
maxWidth?: number;
@@ -319,9 +320,12 @@ export class MediaGenerator {
leadingStillDuration?: number;
} = {},
): Promise<Buffer> {
const start = Math.max(0, startTime - padding);
const duration = endTime - startTime + 2 * padding;
const { fps = 10, maxWidth = 640, maxHeight, crf = 35, leadingStillDuration = 0 } = options;
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
const start = Math.max(0, startTime);
const duration = endTime - startTime + safePadding;
const effectiveLeadingPadding = Math.min(safePadding, start);
const totalLeadingStillDuration = Math.max(0, leadingStillDuration) + effectiveLeadingPadding;
const clampedCrf = Math.max(0, Math.min(63, crf));
@@ -359,7 +363,7 @@ export class MediaGenerator {
fps,
maxWidth,
maxHeight,
leadingStillDuration,
leadingStillDuration: totalLeadingStillDuration,
}),
...encoderArgs,
'-y',
+7
View File
@@ -54,6 +54,7 @@ import type {
ControllerConfigUpdate,
ControllerPreferenceUpdate,
ResolvedControllerConfig,
SessionNumericSelectionStartPayload,
YoutubePickerOpenPayload,
YoutubePickerResolveRequest,
YoutubePickerResolveResult,
@@ -171,6 +172,11 @@ const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.pl
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.youtubePickerCancel,
);
const onSessionNumericSelectionStartEvent =
createQueuedIpcListenerWithPayload<SessionNumericSelectionStartPayload>(
IPC_CHANNELS.event.sessionNumericSelectionStart,
(payload) => payload as SessionNumericSelectionStartPayload,
);
const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener(
IPC_CHANNELS.event.keyboardModeToggleRequested,
);
@@ -385,6 +391,7 @@ const electronAPI: ElectronAPI = {
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent,
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
onSessionNumericSelectionStart: onSessionNumericSelectionStartEvent,
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
+15
View File
@@ -670,6 +670,21 @@ test('numeric selection ignores non-digit keys instead of falling through to oth
}
});
test('numeric selection start focuses overlay for follow-up digit keys', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.beginSessionNumericSelection('copySubtitleMultiple');
assert.equal(testGlobals.focusMainWindowCalls() > 0, true);
assert.equal(testGlobals.windowFocusCalls() > 0, true);
assert.equal(testGlobals.overlayFocusCalls.length > 0, true);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: left and right move token selection while popup remains open', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
+5 -2
View File
@@ -147,6 +147,7 @@ export function createKeyboardHandlers(
function startPendingNumericSelection(
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
timeoutMs: number = ctx.state.sessionActionTimeoutMs,
): void {
cancelPendingNumericSelection(false);
const timeoutMessage = actionId === 'copySubtitleMultiple' ? 'Copy timeout' : 'Mine timeout';
@@ -159,15 +160,17 @@ export function createKeyboardHandlers(
timeout: setTimeout(() => {
pendingNumericSelection = null;
showSessionSelectionMessage(timeoutMessage);
}, ctx.state.sessionActionTimeoutMs),
}, timeoutMs),
};
showSessionSelectionMessage(promptMessage);
}
function beginSessionNumericSelection(
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple',
timeoutMs?: number,
): void {
startPendingNumericSelection(actionId);
startPendingNumericSelection(actionId, timeoutMs);
restoreOverlayKeyboardFocus();
}
function handlePendingNumericSelection(e: KeyboardEvent): boolean {
+6
View File
@@ -530,6 +530,12 @@ function registerModalOpenHandlers(): void {
}
function registerKeyboardCommandHandlers(): void {
window.electronAPI.onSessionNumericSelectionStart((payload) => {
runGuarded('session:numeric-selection-start', () => {
keyboardHandlers.beginSessionNumericSelection(payload.actionId, payload.timeoutMs);
});
});
window.electronAPI.onKeyboardModeToggleRequested(() => {
runGuarded('keyboard-mode-toggle:requested', () => {
keyboardHandlers.handleKeyboardModeToggleRequested();
+1
View File
@@ -123,6 +123,7 @@ export const IPC_CHANNELS = {
youtubePickerOpen: 'youtube:picker-open',
youtubePickerCancel: 'youtube:picker-cancel',
playlistBrowserOpen: 'playlist-browser:open',
sessionNumericSelectionStart: 'session:numeric-selection-start',
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
sessionHelpOpen: 'session-help:open',
+29 -1
View File
@@ -27,9 +27,17 @@ interface HistoryEntry {
timingKey: string;
startTime: number;
endTime: number;
secondaryText?: string;
timestamp: number;
}
export interface SubtitleTimingBlock {
displayText: string;
startTime: number;
endTime: number;
secondaryText?: string;
}
export class SubtitleTimingTracker {
private timings = new Map<string, TimingEntry>();
private history: HistoryEntry[] = [];
@@ -41,11 +49,12 @@ export class SubtitleTimingTracker {
this.startCleanup();
}
recordSubtitle(text: string, startTime: number, endTime: number): void {
recordSubtitle(text: string, startTime: number, endTime: number, secondaryText?: string): void {
const normalizedText = this.normalizeText(text);
if (!normalizedText) return;
const displayText = this.prepareDisplayText(text);
const displaySecondaryText = secondaryText ? this.prepareDisplayText(secondaryText) : undefined;
const timingKey = normalizedText;
this.timings.set(timingKey, {
@@ -60,6 +69,7 @@ export class SubtitleTimingTracker {
// Update timing to most recent occurrence
lastEntry.startTime = startTime;
lastEntry.endTime = endTime;
lastEntry.secondaryText = displaySecondaryText;
lastEntry.timestamp = Date.now();
return;
}
@@ -69,6 +79,7 @@ export class SubtitleTimingTracker {
timingKey,
startTime,
endTime,
secondaryText: displaySecondaryText,
timestamp: Date.now(),
});
@@ -106,6 +117,23 @@ export class SubtitleTimingTracker {
return this.history.slice(-count).map((entry) => entry.displayText);
}
/**
* Get recent subtitle blocks with their original event timings.
* Returns the last `count` subtitle events (oldest newest).
*/
getRecentEntries(count: number): SubtitleTimingBlock[] {
if (count <= 0) return [];
if (count > this.history.length) {
count = this.history.length;
}
return this.history.slice(-count).map((entry) => ({
displayText: entry.displayText,
startTime: entry.startTime,
endTime: entry.endTime,
secondaryText: entry.secondaryText,
}));
}
/**
* Get display text for the most recent subtitle.
*/
+8
View File
@@ -378,6 +378,11 @@ export interface CharacterDictionarySelectionResult {
staleMediaIds: number[];
}
export interface SessionNumericSelectionStartPayload {
actionId: Extract<SessionActionId, 'copySubtitleMultiple' | 'mineSentenceMultiple'>;
timeoutMs: number;
}
export interface ElectronAPI {
getOverlayLayer: () => 'visible' | 'modal' | null;
onSubtitle: (callback: (data: SubtitleData) => void) => void;
@@ -451,6 +456,9 @@ export interface ElectronAPI {
onSubtitleSidebarToggle: (callback: () => void) => void;
onPrimarySubtitleBarToggle: (callback: () => void) => void;
onCancelYoutubeTrackPicker: (callback: () => void) => void;
onSessionNumericSelectionStart: (
callback: (payload: SessionNumericSelectionStartPayload) => void,
) => void;
onKeyboardModeToggleRequested: (callback: () => void) => void;
onLookupWindowToggleRequested: (callback: () => void) => void;
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;