@@ -232,18 +232,30 @@ const VDO_NINJA_ORIGIN = 'https://vdo.ninja';
232232let lastAutoDelayMs : number | null = null ;
233233let activeDelayMs : number | null = null ;
234234let 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). */
284302export 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
292314function 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