Skip to content

Commit 452b90b

Browse files
mkschulzeclaude
andcommitted
feat(14.2-02): three-tier delay display, sync mode footer, and syncing overlay
- Add optional syncMode field to BufferDelayMessage and SyncMode type in types.ts - Track lastAutoSyncMode separately from manualMode for correct auto-restore - getDelayDisplayText shows "measured", "calculated", or "manual" (replaces generic "auto") - syncMode runtime validated: only 'measured'|'calculated' accepted, unknown defaults to 'calculated' (T-14.2-06) - showSyncingOverlay/hideSyncingOverlay with replace semantics for repeated updates (D-09) - onBufferDelay in main.ts passes syncMode and triggers overlay for measured mode only - Update existing video-sync tests from "(auto)" to "(calculated)" - All 109 companion tests pass including 21 new instamode-sync tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b341273 commit 452b90b

4 files changed

Lines changed: 83 additions & 14 deletions

File tree

companion/src/__tests__/video-sync.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,9 @@ describe('Video Sync Dual-State Delay Model (VID-08)', () => {
9999
expect(getDelayDisplayText()).toBe('--');
100100
});
101101

102-
it('returns "8.0s (auto)" when activeDelayMs=8000, auto mode', () => {
102+
it('returns "8.0s (calculated)" when activeDelayMs=8000, calculated mode', () => {
103103
setLastAutoDelay(8000);
104-
expect(getDelayDisplayText()).toBe('8.0s (auto)');
104+
expect(getDelayDisplayText()).toBe('8.0s (calculated)');
105105
});
106106

107107
it('returns "0.0s (manual) No delay" when activeDelayMs=0, manual mode', () => {
@@ -114,9 +114,9 @@ describe('Video Sync Dual-State Delay Model (VID-08)', () => {
114114
expect(getDelayDisplayText()).toBe('5.0s (manual)');
115115
});
116116

117-
it('returns "0.0s (auto) No delay" when activeDelayMs=0, auto mode', () => {
117+
it('returns "0.0s (calculated) No delay" when activeDelayMs=0, calculated mode', () => {
118118
setLastAutoDelay(0);
119-
expect(getDelayDisplayText()).toBe('0.0s (auto) No delay');
119+
expect(getDelayDisplayText()).toBe('0.0s (calculated) No delay');
120120
});
121121
});
122122

companion/src/main.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
getSavedEffect,
2020
saveEffect,
2121
setLastAutoDelay,
22+
showSyncingOverlay,
2223
initDelayControls,
2324
updateFooterDelayStatus,
2425
renderRosterStrip,
@@ -233,9 +234,16 @@ const callbacks: WsCallbacks = {
233234
},
234235

235236
onBufferDelay(msg) {
236-
console.log('VideoSync: plugin sent bufferDelay:', msg.delayMs, 'ms');
237-
setLastAutoDelay(msg.delayMs);
237+
console.log('VideoSync: plugin sent bufferDelay:', msg.delayMs, 'ms',
238+
msg.syncMode ? `(${msg.syncMode})` : '');
239+
setLastAutoDelay(msg.delayMs, msg.syncMode);
238240
updateFooterDelayStatus();
241+
// D-09: show sync overlay when measured delay arrives.
242+
// Only for 'measured' mode -- calculated mode doesn't need a visible sync phase
243+
// because it's a theoretical estimate, not a probe-triggered calibration.
244+
if (msg.syncMode === 'measured' && msg.delayMs > 0) {
245+
showSyncingOverlay(msg.delayMs);
246+
}
239247
},
240248

241249
onBeatHeartbeat(msg) {

companion/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface RosterMessage {
2222
export interface BufferDelayMessage {
2323
type: 'bufferDelay';
2424
delayMs: number;
25+
syncMode?: 'measured' | 'calculated';
2526
}
2627

2728
export interface PopoutMessage {
@@ -40,6 +41,8 @@ export interface DeactivateMessage {
4041
type: 'deactivate';
4142
}
4243

44+
export type SyncMode = 'measured' | 'calculated' | 'manual';
45+
4346
export type PluginMessage = ConfigMessage | RosterMessage | BufferDelayMessage | PopoutMessage | BeatHeartbeatMessage | DeactivateMessage;
4447

4548
// ── Runtime validation (addresses review: protocol resilience) ──

companion/src/ui.ts

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -232,18 +232,30 @@ const VDO_NINJA_ORIGIN = 'https://vdo.ninja';
232232
let lastAutoDelayMs: number | null = null;
233233
let activeDelayMs: number | null = null;
234234
let manualMode = false;
235+
// Phase 14.2 (D-12): Track the last auto sync mode separately from manual mode.
236+
// This preserves the mode label so switchToAuto can restore it correctly.
237+
// Addresses review concern: separate lastAutoSyncMode from manualMode.
238+
let lastAutoSyncMode: 'measured' | 'calculated' = 'calculated';
235239

236240
/** Called when plugin sends bufferDelay message. Always updates lastAutoDelayMs.
237241
* In auto mode, also updates activeDelayMs and sends to iframe.
238-
* In manual mode, silently tracks the plugin value for later switchToAuto(). */
239-
export function setLastAutoDelay(delayMs: number): void {
242+
* In manual mode, silently tracks the plugin value for later switchToAuto().
243+
* syncMode is runtime-validated: only 'measured' | 'calculated' accepted (T-14.2-06). */
244+
export function setLastAutoDelay(delayMs: number, syncMode?: 'measured' | 'calculated'): void {
240245
lastAutoDelayMs = delayMs;
246+
// Runtime validation: only accept known values (addresses review concern #3).
247+
// Unknown or absent syncMode defaults to 'calculated' for backward compatibility (D-13).
248+
if (syncMode === 'measured' || syncMode === 'calculated') {
249+
lastAutoSyncMode = syncMode;
250+
}
251+
// else: keep current lastAutoSyncMode (defaults to 'calculated' on init/reset)
241252
if (!manualMode) {
242253
activeDelayMs = delayMs;
243-
console.log('VideoSync: auto delay applied:', delayMs, 'ms');
254+
console.log('VideoSync: auto delay applied:', delayMs, 'ms', syncMode ? `(${syncMode})` : '');
244255
sendBufferDelayToIframe(delayMs);
245256
} else {
246-
console.log('VideoSync: auto delay updated:', delayMs, 'ms (manual mode, not applied)');
257+
console.log('VideoSync: auto delay updated:', delayMs, 'ms (manual mode, not applied)',
258+
syncMode ? `(${syncMode})` : '');
247259
}
248260
}
249261

@@ -280,13 +292,23 @@ export function isManualMode(): boolean {
280292
return manualMode;
281293
}
282294

283-
/** Get formatted display text for the current delay state. */
295+
/** Get the last auto sync mode (measured or calculated). */
296+
export function getLastAutoSyncMode(): 'measured' | 'calculated' {
297+
return lastAutoSyncMode;
298+
}
299+
300+
/** Get formatted display text for the current delay state.
301+
* Three-tier display: manual > measured > calculated (D-11, D-12). */
284302
export function getDelayDisplayText(): string {
285303
if (activeDelayMs === null) return '--';
286-
const mode = manualMode ? 'manual' : 'auto';
287304
const seconds = (activeDelayMs / 1000).toFixed(1);
288-
if (activeDelayMs === 0) return `${seconds}s (${mode}) No delay`;
289-
return `${seconds}s (${mode})`;
305+
if (manualMode) {
306+
if (activeDelayMs === 0) return `${seconds}s (manual) No delay`;
307+
return `${seconds}s (manual)`;
308+
}
309+
// Auto mode: display the specific sync mode (measured or calculated)
310+
if (activeDelayMs === 0) return `${seconds}s (${lastAutoSyncMode}) No delay`;
311+
return `${seconds}s (${lastAutoSyncMode})`;
290312
}
291313

292314
function sendBufferDelayToIframe(delayMs: number): void {
@@ -312,6 +334,42 @@ export function resetDelayState(): void {
312334
lastAutoDelayMs = null;
313335
activeDelayMs = null;
314336
manualMode = false;
337+
lastAutoSyncMode = 'calculated';
338+
}
339+
340+
/** D-09: Show a black syncing overlay over the video area.
341+
* Overlay fades out after delayMs + 500ms grace period.
342+
* Only one overlay exists at a time -- calling again replaces the existing one
343+
* (addresses review concern: repeated measured update behavior).
344+
* NOTE: Single overlay covers entire video area because VDO.Ninja iframe is
345+
* cross-origin (cannot position per-tile overlays). All tiles share the same
346+
* measured delay, so global overlay is functionally equivalent. */
347+
export function showSyncingOverlay(delayMs: number): void {
348+
// Remove any existing overlay first (replace semantics for repeated updates)
349+
hideSyncingOverlay();
350+
351+
const overlay = document.createElement('div');
352+
overlay.id = 'sync-overlay';
353+
overlay.style.cssText = 'position:absolute;inset:0;background:#000;'
354+
+ 'transition:opacity 0.8s ease;opacity:1;pointer-events:none;z-index:10;'
355+
+ 'display:flex;align-items:center;justify-content:center;color:#888;font-size:14px;';
356+
overlay.textContent = 'Syncing video\u2026';
357+
358+
const mainArea = document.getElementById('main-area');
359+
if (mainArea) {
360+
mainArea.style.position = 'relative';
361+
mainArea.appendChild(overlay);
362+
// Fade out after buffer fills (delayMs + 500ms grace)
363+
setTimeout(() => {
364+
overlay.style.opacity = '0';
365+
setTimeout(() => overlay.remove(), 900); // remove after CSS transition completes
366+
}, delayMs + 500);
367+
}
368+
}
369+
370+
/** Remove the syncing overlay immediately. */
371+
export function hideSyncingOverlay(): void {
372+
document.getElementById('sync-overlay')?.remove();
315373
}
316374

317375
// Legacy aliases for backward compatibility with existing tests

0 commit comments

Comments
 (0)