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
- 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.
## Subtitle Format Handling
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
getPlaybackFeedbackNotificationOptions,
getYoutubeFlowStatusNotificationOptions,
notifyConfiguredStatus,
} from './configured-status-notification';
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'), {});
});
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', () => {
const calls: string[] = [];
@@ -31,6 +31,25 @@ export function getPlaybackFeedbackNotificationOptions(
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(
message: string,
deps: ConfiguredStatusNotificationDeps,
@@ -22,6 +22,7 @@ import { withConfiguredOverlayNotificationPosition } from './overlay-notificatio
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
import {
getPlaybackFeedbackNotificationOptions,
getYoutubeFlowStatusNotificationOptions,
notifyConfiguredStatus,
type ConfiguredStatusNotificationOptions,
} from './configured-status-notification';
@@ -206,18 +207,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
}
function showYoutubeFlowStatusNotification(message: string): void {
const progress =
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,
});
showConfiguredStatusNotification(message, getYoutubeFlowStatusNotificationOptions(message));
}
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
+62
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { createYoutubeFlowRuntime } from './youtube-flow';
import type { YoutubeTrackProbeResult } from '../../core/services/youtube/track-probe';
import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types';
const primaryTrack: YoutubeTrackOption = {
@@ -20,6 +21,66 @@ const secondaryTrack: YoutubeTrackOption = {
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 () => {
const commands: Array<Array<string | number>> = [];
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.ok(waits.includes(150));
assert.deepEqual(osdMessages, [
'Opening YouTube subtitle picker...',
'Getting subtitles...',
'Downloading subtitles...',
'Loading subtitles...',
+2
View File
@@ -755,6 +755,8 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
url: string;
mode?: YoutubeFlowMode;
}): Promise<void> => {
deps.showMpvOsd('Opening YouTube subtitle picker...');
let probe: YoutubeTrackProbeResult;
try {
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', () => {
assert.match(
overlayNotificationCss,