feat(overlay): add loading OSD spinner and queue notifications until ren

- Show mpv OSD spinner from start-file until subminer-overlay-loading-ready; force-shown for visible-overlay startup regardless of osd_messages setting
- Gate non-macOS overlay visibility on content-ready so first subtitle line is immediately hoverable and clickable
- Queue startup notifications in main process until overlay window finishes loading; upsert progress cards by id to avoid cold-start floods
- Defer background warmups until after overlay runtime init so queued notifications can deliver promptly
- Preserve character dictionary checking/building/importing/ready phases as distinct history entries; route building and importing to system notifications when notificationType is both
This commit is contained in:
2026-06-07 23:13:51 -07:00
parent d033884b09
commit 9d77907877
49 changed files with 1613 additions and 132 deletions
@@ -4,6 +4,7 @@ import {
getPlaybackFeedbackNotificationOptions,
notifyConfiguredStatus,
} from './configured-status-notification';
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
test('notifyConfiguredStatus routes both to overlay and system without osd', () => {
const calls: string[] = [];
@@ -27,7 +28,7 @@ test('notifyConfiguredStatus routes both to overlay and system without osd', ()
]);
});
test('notifyConfiguredStatus routes pre-overlay both status to osd and desktop', () => {
test('notifyConfiguredStatus queues pre-overlay both status through overlay sender and desktop', () => {
const calls: string[] = [];
notifyConfiguredStatus('Overlay loading...', {
@@ -42,7 +43,25 @@ test('notifyConfiguredStatus routes pre-overlay both status to osd and desktop',
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['osd:Overlay loading...', 'desktop:SubMiner:Overlay loading...']);
assert.deepEqual(calls, ['overlay::Overlay loading...', 'desktop:SubMiner:Overlay loading...']);
});
test('notifyConfiguredStatus queues pre-overlay overlay-only status without osd fallback', () => {
const calls: string[] = [];
notifyConfiguredStatus('Overlay loading...', {
getNotificationType: () => 'overlay',
isOverlayReady: () => false,
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showOverlayNotification: (payload) =>
calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['overlay::Overlay loading...']);
});
test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => {
@@ -190,3 +209,231 @@ test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', (
assert.deepEqual(calls, ['desktop:SubMiner:Overlay unavailable.']);
});
test('overlay notification delivery queues until an overlay window is ready', () => {
const sent: string[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
});
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' });
delivery.send({ id: 'character-dictionary-auto-sync', title: 'Dictionary', body: 'Building' });
assert.equal(delivery.getQueuedCount(), 2);
assert.deepEqual(sent, []);
ready = true;
delivery.flush();
assert.equal(delivery.getQueuedCount(), 0);
assert.deepEqual(sent, [
'startup-tokenization:Loading',
'character-dictionary-auto-sync:Building',
]);
});
test('overlay notification delivery upserts queued progress by notification id', () => {
const sent: string[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
});
delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '|' });
delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '/' });
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Ready' });
ready = true;
delivery.flush();
assert.deepEqual(sent, ['startup-subtitle-annotations:/', 'startup-tokenization:Ready']);
});
test('overlay notification delivery preserves queued events with distinct history ids', () => {
const sent: string[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) =>
sent.push(
`${payload.id ?? ''}:${'historyId' in payload ? payload.historyId : ''}:${'body' in payload ? payload.body : ''}`,
),
});
delivery.send({
id: 'character-dictionary-auto-sync',
historyId: 'character-dictionary-auto-sync-checking',
title: 'Character dictionary',
body: 'Checking character dictionary...',
persistent: true,
});
delivery.send({
id: 'character-dictionary-auto-sync',
historyId: 'character-dictionary-auto-sync-building',
title: 'Character dictionary',
body: 'Building character dictionary...',
persistent: true,
});
ready = true;
delivery.flush();
assert.deepEqual(sent, [
'character-dictionary-auto-sync:character-dictionary-auto-sync-checking:Checking character dictionary...',
'character-dictionary-auto-sync:character-dictionary-auto-sync-building:Building character dictionary...',
]);
});
test('overlay notification delivery preserves queued startup progress before terminal update', () => {
const sent: string[] = [];
const scheduled: Array<() => void> = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) =>
sent.push(
`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`,
),
scheduleFlushRetry: (callback) => {
scheduled.push(callback);
},
});
delivery.send({
id: 'startup-tokenization',
title: 'Subtitle tokenization',
body: 'Loading subtitle tokenization...',
variant: 'progress',
persistent: true,
});
delivery.send({
id: 'startup-tokenization',
title: 'Subtitle tokenization',
body: 'Subtitle tokenization ready',
variant: 'success',
persistent: false,
});
ready = true;
delivery.flush();
scheduled.shift()?.();
assert.deepEqual(sent, [
'startup-tokenization:Loading subtitle tokenization...:pin',
'startup-tokenization:Subtitle tokenization ready:auto',
]);
});
test('overlay notification delivery defers terminal update after first queued progress paint', () => {
const sent: string[] = [];
const scheduled: Array<() => void> = [];
const delays: number[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) =>
sent.push(
`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`,
),
scheduleFlushRetry: (callback, delayMs) => {
scheduled.push(callback);
delays.push(delayMs);
},
terminalUpdateDelayMs: 750,
});
delivery.send({
id: 'startup-subtitle-annotations',
title: 'Subtitle annotations',
body: 'Loading subtitle annotations |',
variant: 'progress',
persistent: true,
});
delivery.send({
id: 'startup-subtitle-annotations',
title: 'Subtitle annotations',
body: 'Subtitle annotations loaded',
variant: 'success',
persistent: false,
});
ready = true;
delivery.flush();
assert.deepEqual(sent, ['startup-subtitle-annotations:Loading subtitle annotations |:pin']);
assert.equal(delivery.getQueuedCount(), 1);
assert.deepEqual(delays, [750]);
scheduled.shift()?.();
assert.equal(delivery.getQueuedCount(), 0);
assert.deepEqual(sent, [
'startup-subtitle-annotations:Loading subtitle annotations |:pin',
'startup-subtitle-annotations:Subtitle annotations loaded:auto',
]);
});
test('overlay notification delivery retries flush when lifecycle fires before window readiness settles', () => {
const sent: string[] = [];
const scheduled: Array<() => void> = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
scheduleFlushRetry: (callback) => {
scheduled.push(callback);
},
});
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' });
delivery.flush();
assert.equal(delivery.getQueuedCount(), 1);
assert.equal(scheduled.length, 1);
assert.deepEqual(sent, []);
ready = true;
scheduled.shift()?.();
assert.equal(delivery.getQueuedCount(), 0);
assert.deepEqual(sent, ['startup-tokenization:Loading']);
});
test('overlay notification delivery drops queued notification when dismissed before flush', () => {
const sent: string[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) =>
sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`),
});
delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' });
delivery.send({ id: 'overlay-loading-status', dismiss: true });
ready = true;
delivery.flush();
assert.deepEqual(sent, []);
});
test('overlay notification delivery removes queued notification when dismissed at readiness', () => {
const sent: string[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) =>
sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`),
});
delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' });
ready = true;
delivery.send({ id: 'overlay-loading-status', dismiss: true });
delivery.flush();
assert.deepEqual(sent, ['dismiss:overlay-loading-status']);
});