feat(youtube): notify on manual picker open and show success after track load (#133)

This commit is contained in:
2026-06-28 22:43:16 -07:00
committed by GitHub
parent 389d8e06e0
commit f65afa6046
8 changed files with 210 additions and 12 deletions
@@ -0,0 +1,4 @@
type: fixed
area: youtube
- Manual YouTube subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal, then replace subtitle download progress with a transient success notification after tracks load.
+4
View File
@@ -74,6 +74,10 @@ Press **Ctrl+Alt+C** during YouTube playback to open the subtitle picker overlay
- Select different primary and secondary tracks - Select different primary and secondary tracks
- Retry track loading if the auto-load failed or picked the wrong track - Retry track loading if the auto-load failed or picked the wrong track
SubMiner shows an "Opening YouTube subtitle picker..." status through your configured notification
surface while it probes tracks and prepares the modal, then updates the subtitle download progress
card to a success notification after the selected tracks load.
The picker displays each track with its language, kind (manual/auto), and title when available. The picker displays each track with its language, kind (manual/auto), and title when available.
## Subtitle Format Handling ## Subtitle Format Handling
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
getPlaybackFeedbackNotificationOptions, getPlaybackFeedbackNotificationOptions,
getYoutubeFlowStatusNotificationOptions,
notifyConfiguredStatus, notifyConfiguredStatus,
} from './configured-status-notification'; } from './configured-status-notification';
import { createOverlayNotificationDelivery } from './overlay-notification-delivery'; import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
@@ -226,6 +227,36 @@ test('playback feedback options reuse subtitle mode notification ids', () => {
assert.deepEqual(getPlaybackFeedbackNotificationOptions('Secondary subtitle track: English'), {}); assert.deepEqual(getPlaybackFeedbackNotificationOptions('Secondary subtitle track: English'), {});
}); });
test('youtube flow status options route picker opening as one-shot configured status', () => {
assert.deepEqual(getYoutubeFlowStatusNotificationOptions('Opening YouTube subtitle picker...'), {
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
variant: 'info',
persistent: false,
desktop: true,
});
});
test('youtube flow status options route loaded messages as transient success', () => {
assert.deepEqual(getYoutubeFlowStatusNotificationOptions('Subtitles loaded.'), {
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
variant: 'success',
persistent: false,
desktop: true,
});
assert.deepEqual(
getYoutubeFlowStatusNotificationOptions('Primary and secondary subtitles loaded.'),
{
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
variant: 'success',
persistent: false,
desktop: true,
},
);
});
test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', () => { test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', () => {
const calls: string[] = []; const calls: string[] = [];
@@ -31,6 +31,25 @@ export function getPlaybackFeedbackNotificationOptions(
return {}; return {};
} }
export function getYoutubeFlowStatusNotificationOptions(
message: string,
): ConfiguredStatusNotificationOptions {
const success =
message === 'Subtitles loaded.' || message === 'Primary and secondary subtitles loaded.';
const progress =
message.startsWith('Downloading subtitles') ||
message.startsWith('Loading subtitles') ||
message.startsWith('Getting subtitles') ||
message === 'Opening YouTube video';
return {
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
variant: success ? 'success' : progress ? 'progress' : 'info',
persistent: progress,
desktop: !progress,
};
}
export function notifyConfiguredStatus( export function notifyConfiguredStatus(
message: string, message: string,
deps: ConfiguredStatusNotificationDeps, deps: ConfiguredStatusNotificationDeps,
@@ -22,6 +22,7 @@ import { withConfiguredOverlayNotificationPosition } from './overlay-notificatio
import { createOverlayNotificationDelivery } from './overlay-notification-delivery'; import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
import { import {
getPlaybackFeedbackNotificationOptions, getPlaybackFeedbackNotificationOptions,
getYoutubeFlowStatusNotificationOptions,
notifyConfiguredStatus, notifyConfiguredStatus,
type ConfiguredStatusNotificationOptions, type ConfiguredStatusNotificationOptions,
} from './configured-status-notification'; } from './configured-status-notification';
@@ -206,18 +207,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
} }
function showYoutubeFlowStatusNotification(message: string): void { function showYoutubeFlowStatusNotification(message: string): void {
const progress = showConfiguredStatusNotification(message, getYoutubeFlowStatusNotificationOptions(message));
message.startsWith('Downloading subtitles') ||
message.startsWith('Loading subtitles') ||
message.startsWith('Getting subtitles') ||
message === 'Opening YouTube video';
showConfiguredStatusNotification(message, {
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
variant: progress ? 'progress' : 'info',
persistent: progress,
desktop: !progress,
});
} }
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> { function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
+62
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { createYoutubeFlowRuntime } from './youtube-flow'; import { createYoutubeFlowRuntime } from './youtube-flow';
import type { YoutubeTrackProbeResult } from '../../core/services/youtube/track-probe';
import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types'; import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types';
const primaryTrack: YoutubeTrackOption = { const primaryTrack: YoutubeTrackOption = {
@@ -20,6 +21,66 @@ const secondaryTrack: YoutubeTrackOption = {
label: 'English (manual)', label: 'English (manual)',
}; };
test('youtube flow announces manual picker opening before probing tracks', async () => {
const osdMessages: string[] = [];
let resolveProbe: (probe: YoutubeTrackProbeResult) => void = () => {};
const probePromise = new Promise<YoutubeTrackProbeResult>((resolve) => {
resolveProbe = resolve;
});
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => await probePromise,
acquireYoutubeSubtitleTracks: async () => new Map(),
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/unused.vtt' }),
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'continue-without-subtitles',
primaryTrackId: null,
secondaryTrackId: null,
});
});
return true;
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: () => {},
requestMpvProperty: async () => null,
refreshCurrentSubtitle: () => {},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: (text) => {
osdMessages.push(text);
},
reportSubtitleFailure: (message) => {
throw new Error(message);
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
const pending = runtime.openManualPicker({ url: 'https://example.com' });
await Promise.resolve();
assert.deepEqual(osdMessages, ['Opening YouTube subtitle picker...']);
resolveProbe({
videoId: 'video123',
title: 'Video 123',
tracks: [],
});
await pending;
});
test('youtube flow can open a manual picker session and load the selected subtitles', async () => { test('youtube flow can open a manual picker session and load the selected subtitles', async () => {
const commands: Array<Array<string | number>> = []; const commands: Array<Array<string | number>> = [];
const focusOverlayCalls: string[] = []; const focusOverlayCalls: string[] = [];
@@ -126,6 +187,7 @@ test('youtube flow can open a manual picker session and load the selected subtit
assert.equal(openedPayloads[0]?.defaultSecondaryTrackId, secondaryTrack.id); assert.equal(openedPayloads[0]?.defaultSecondaryTrackId, secondaryTrack.id);
assert.ok(waits.includes(150)); assert.ok(waits.includes(150));
assert.deepEqual(osdMessages, [ assert.deepEqual(osdMessages, [
'Opening YouTube subtitle picker...',
'Getting subtitles...', 'Getting subtitles...',
'Downloading subtitles...', 'Downloading subtitles...',
'Loading subtitles...', 'Loading subtitles...',
+2
View File
@@ -755,6 +755,8 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
url: string; url: string;
mode?: YoutubeFlowMode; mode?: YoutubeFlowMode;
}): Promise<void> => { }): Promise<void> => {
deps.showMpvOsd('Opening YouTube subtitle picker...');
let probe: YoutubeTrackProbeResult; let probe: YoutubeTrackProbeResult;
try { try {
probe = await deps.probeYoutubeTracks(input.url); probe = await deps.probeYoutubeTracks(input.url);
@@ -384,6 +384,92 @@ test('overlay notification renderer updates same-id progress without replacing t
} }
}); });
test('overlay notification renderer auto-dismisses same-id terminal update after persistent progress', () => {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const stack = createFakeElement();
let nextTimerId = 1;
const timers = new Map<number, { callback: () => void; delayMs: number }>();
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: (tagName: string) => createFakeElement(tagName),
},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
clearTimeout: (id: number) => {
timers.delete(id);
},
setTimeout: (callback: () => void, delayMs: number) => {
const id = nextTimerId;
nextTimerId += 1;
timers.set(id, { callback, delayMs });
return id;
},
},
});
try {
const renderer = createOverlayNotificationRenderer({
dom: {
overlayNotificationStack: stack,
},
state: {
isOverOverlayNotification: false,
},
} as never);
renderer.show({
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
body: 'Downloading subtitles...',
variant: 'progress',
persistent: true,
});
const card = stack.children[0];
if (!card) {
assert.fail('Expected overlay notification card.');
}
renderer.show({
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
body: 'Subtitles loaded.',
variant: 'success',
persistent: false,
});
const autoDismissTimer = [...timers.values()].find((timer) => timer.delayMs === 3000);
assert.ok(autoDismissTimer, 'Expected terminal update to schedule an auto-dismiss timer.');
assert.equal(stack.children[0], card);
assert.equal(
findChildByClass(card, 'overlay-notification-body')?.textContent,
'Subtitles loaded.',
);
assert.equal(card.classList.contains('success'), true);
autoDismissTimer.callback();
assert.equal(card.classList.contains('leaving'), true);
} finally {
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument);
} else {
delete (globalThis as { document?: unknown }).document;
}
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
test('overlay notification cards use larger display dimensions', () => { test('overlay notification cards use larger display dimensions', () => {
assert.match( assert.match(
overlayNotificationCss, overlayNotificationCss,