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
@@ -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,