@dolby-optiview/ads-sdk-core 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/OptiViewAds.d.ts +5 -0
  2. package/dist/OptiViewAds.js +1 -1
  3. package/dist/diagnostics/dispatchDiagnostic.d.ts +4 -0
  4. package/dist/diagnostics/dispatchDiagnostic.js +1 -0
  5. package/dist/generated/diagnosticCodes.d.ts +104 -4
  6. package/dist/generated/diagnosticCodes.js +1 -1
  7. package/dist/generated/version.d.ts +1 -1
  8. package/dist/generated/version.js +1 -1
  9. package/dist/index.d.ts +11 -4
  10. package/dist/index.js +1 -1
  11. package/dist/interfaces/PlayerAdapter.d.ts +27 -0
  12. package/dist/server/index.d.ts +2 -2
  13. package/dist/server/index.js +1 -1
  14. package/dist/services/AdBreakSequencer.d.ts +67 -3
  15. package/dist/services/AdBreakSequencer.js +1 -1
  16. package/dist/services/AdPlayerController.d.ts +9 -4
  17. package/dist/services/AdPlayerController.js +1 -1
  18. package/dist/services/BookmarkStart.d.ts +12 -0
  19. package/dist/services/BookmarkStart.js +1 -0
  20. package/dist/services/BreakScheduler.d.ts +8 -35
  21. package/dist/services/BreakScheduler.js +1 -1
  22. package/dist/services/BreakSchedulerTypes.d.ts +7 -1
  23. package/dist/services/BreakWarningTracker.d.ts +6 -0
  24. package/dist/services/BreakWarningTracker.js +1 -0
  25. package/dist/services/ContentEndTracker.d.ts +17 -0
  26. package/dist/services/ContentEndTracker.js +1 -0
  27. package/dist/services/ExternalPlaybackGate.d.ts +2 -2
  28. package/dist/services/ManifestService.d.ts +1 -1
  29. package/dist/services/ManifestService.js +1 -1
  30. package/dist/services/PauseAdController.d.ts +9 -5
  31. package/dist/services/PauseAdController.js +1 -1
  32. package/dist/services/PlaybackCrossing.d.ts +4 -0
  33. package/dist/services/PlaybackCrossing.js +1 -0
  34. package/dist/services/RewindRearm.js +1 -1
  35. package/dist/services/SchedulerSnapback.d.ts +1 -0
  36. package/dist/services/SchedulerSnapback.js +1 -1
  37. package/dist/services/SlateClock.d.ts +22 -0
  38. package/dist/services/SlateClock.js +1 -0
  39. package/dist/services/SnapbackGuard.d.ts +36 -2
  40. package/dist/services/SnapbackGuard.js +1 -1
  41. package/dist/services/VastAdManager.d.ts +2 -0
  42. package/dist/services/VastAdManager.js +1 -1
  43. package/dist/services/adPlayer/AdPlayerCallbacks.d.ts +9 -1
  44. package/dist/services/adPlayer/AdPlayerEvents.d.ts +21 -1
  45. package/dist/services/adPlayer/AdPlayerEvents.js +1 -1
  46. package/dist/services/adPlayer/AdPlayerState.d.ts +13 -0
  47. package/dist/services/adPlayer/AdPlayerState.js +1 -1
  48. package/dist/services/adPlayer/AdPreloader.d.ts +8 -1
  49. package/dist/services/adPlayer/AdPreloader.js +1 -1
  50. package/dist/services/adPlayer/AdStage.js +1 -1
  51. package/dist/services/adPlayer/AssetPlayback.d.ts +1 -0
  52. package/dist/services/adPlayer/AssetPlayback.js +1 -1
  53. package/dist/services/adPlayer/BreakSequenceDriver.d.ts +7 -1
  54. package/dist/services/adPlayer/BreakSequenceDriver.js +1 -1
  55. package/dist/services/adPlayer/ContentResumeCoordinator.d.ts +6 -0
  56. package/dist/services/adPlayer/ContentResumeCoordinator.js +1 -1
  57. package/dist/services/adPlayer/PauseAdPresenter.d.ts +10 -9
  58. package/dist/services/adPlayer/PauseAdPresenter.js +1 -1
  59. package/dist/services/adPlayer/PauseAdTracking.d.ts +13 -0
  60. package/dist/services/adPlayer/PauseAdTracking.js +1 -0
  61. package/dist/services/adPlayer/SharedElementBreakPlayer.d.ts +31 -2
  62. package/dist/services/adPlayer/SharedElementBreakPlayer.js +1 -1
  63. package/dist/services/adPlayer/SlateCoordinator.d.ts +58 -0
  64. package/dist/services/adPlayer/SlateCoordinator.js +1 -0
  65. package/dist/services/adPlayer/SlatePlayback.d.ts +89 -0
  66. package/dist/services/adPlayer/SlatePlayback.js +1 -0
  67. package/dist/services/adPlayer/VariantSelector.d.ts +10 -1
  68. package/dist/services/adPlayer/VariantSelector.js +1 -1
  69. package/dist/services/adPlayer/connectPauseAd.js +1 -1
  70. package/dist/services/adPlayer/presentation/PresentationLayout.d.ts +5 -2
  71. package/dist/services/adPlayer/presentation/PresentationLayout.js +1 -1
  72. package/dist/services/breakTiming.d.ts +1 -0
  73. package/dist/services/breakTiming.js +1 -1
  74. package/dist/services/parseBreakManifest.d.ts +2 -1
  75. package/dist/services/parseBreakManifest.js +1 -1
  76. package/dist/services/parseVastPause.d.ts +18 -0
  77. package/dist/services/parseVastPause.js +1 -0
  78. package/dist/services/resolveLshapeAnchor.d.ts +25 -0
  79. package/dist/services/resolveLshapeAnchor.js +1 -0
  80. package/dist/services/resolveSlate.d.ts +25 -0
  81. package/dist/services/resolveSlate.js +1 -0
  82. package/dist/services/resolveSlateFill.d.ts +39 -0
  83. package/dist/services/resolveSlateFill.js +1 -0
  84. package/dist/services/ssbSwitchSignals.d.ts +3 -0
  85. package/dist/services/ssbSwitchSignals.js +1 -1
  86. package/dist/session/AdBreakStatusTracker.d.ts +22 -2
  87. package/dist/session/AdBreakStatusTracker.js +1 -1
  88. package/dist/session/BreakLifecycle.js +1 -1
  89. package/dist/session/ContentPlaybackStart.d.ts +23 -0
  90. package/dist/session/ContentPlaybackStart.js +1 -0
  91. package/dist/session/DiscoveredManifestUrl.d.ts +44 -0
  92. package/dist/session/DiscoveredManifestUrl.js +1 -0
  93. package/dist/session/ScheduledBreaksTracker.d.ts +22 -0
  94. package/dist/session/ScheduledBreaksTracker.js +1 -0
  95. package/dist/session/SessionStarter.d.ts +3 -9
  96. package/dist/session/SessionStarter.js +1 -1
  97. package/dist/session/SessionStarterDeps.d.ts +3 -0
  98. package/dist/session/SessionStarterHelpers.d.ts +22 -1
  99. package/dist/session/SessionStarterHelpers.js +1 -1
  100. package/dist/session/SessionState.d.ts +16 -0
  101. package/dist/session/SessionState.js +1 -1
  102. package/dist/session/SsbSwitch.js +1 -1
  103. package/dist/session/buildScheduledBreaks.d.ts +9 -0
  104. package/dist/session/buildScheduledBreaks.js +1 -0
  105. package/dist/session/contentSnapback.d.ts +2 -2
  106. package/dist/session/contentSnapback.js +1 -1
  107. package/dist/session/scheduledBreaksSession.d.ts +6 -0
  108. package/dist/session/scheduledBreaksSession.js +1 -0
  109. package/dist/types/BreakManifest.d.ts +39 -1
  110. package/dist/types/BreakManifest.js +1 -1
  111. package/dist/types/Events.d.ts +13 -4
  112. package/dist/types/OptiViewAdsConfig.d.ts +15 -2
  113. package/dist/types/ScheduledBreak.d.ts +17 -0
  114. package/dist/types/ScheduledBreak.js +1 -0
  115. package/package.json +1 -1
@@ -1,16 +1,17 @@
1
1
  import { PlayerAdapter } from '../interfaces/PlayerAdapter';
2
2
  import type { AdBreakSkipReason } from '../types/Events';
3
- import { Break, BreakManifest } from '../types/BreakManifest';
3
+ import { Break, BreakFormat, BreakManifest } from '../types/BreakManifest';
4
4
  import { type ResolvedBreakWarnings } from '../types/OptiViewAdsConfig';
5
5
  import type { MetadataCueTracker } from './MetadataCueTracker';
6
6
  import type { SnapbackRequest } from './SnapbackGuard';
7
7
  import { type ChainSuccessorChangedCallback } from './BreakChain';
8
- import { type BreakAbortedCallback, type BreakApproachingCallback, type BreakStartCallback, type BreakWarningCallback, type Clock, type SchedulerDiagnose, type TuneInOptions } from './BreakSchedulerTypes';
8
+ import { type BreakAbortedCallback, type BreakApproachingCallback, type BreakStartCallback, type BreakWarningCallback, type Clock, type SchedulerDiagnose, type TuneInOptions, type ScheduledBreaksObserver } from './BreakSchedulerTypes';
9
9
  export { BreakState, systemClock, type BreakAbortedCallback, type BreakApproachingCallback, type BreakStartCallback, type BreakStartContext, type BreakWarningCallback, type Clock, type SchedulerDiagnose, type TuneInOptions, } from './BreakSchedulerTypes';
10
10
  export type { ChainSuccessorChangedCallback } from './BreakChain';
11
11
  /** Scheduling cadence; a break is discovered up to one interval late on the tick alone. */
12
12
  export declare const TICK_INTERVAL_MS = 250;
13
13
  export declare class BreakScheduler {
14
+ scheduleObserver?: ScheduledBreaksObserver | undefined;
14
15
  private player;
15
16
  private breaks;
16
17
  private readonly timing;
@@ -19,11 +20,6 @@ export declare class BreakScheduler {
19
20
  private checkInterval;
20
21
  /** One-shot re-check armed for a wallclock break due inside the next tick (see {@link nextStartDeadlineMs}). */
21
22
  private startDeadline;
22
- /**
23
- * Milliseconds until the earliest pending wallclock break, when that is within one tick; null
24
- * otherwise. Re-derived on every check from the stream's Program Date Time, so a driver that
25
- * ticks the scheduler itself (native SDKs) can arm the same one-shot the web scheduler arms.
26
- */
27
23
  nextStartDeadlineMs: number | null;
28
24
  private onBreakStart;
29
25
  private onBreakApproaching;
@@ -40,15 +36,12 @@ export declare class BreakScheduler {
40
36
  private breakWarnings;
41
37
  /** Warning thresholds already fired per break, so each `breakWarnings.seconds` value emits once. */
42
38
  private breakWarningFired;
43
- /**
44
- * Completed break ids. The manifest keeps delivering a completed break, so without this
45
- * ledger {@link updateManifest} would re-add it as PENDING and replay it (most visibly pre-rolls).
46
- */
47
39
  private completedBreakIds;
48
40
  /** Breaks already reported as DA-BREAK-EVENT-UNSUPPORTED, so a re-poll does not repeat the diagnostic. */
49
41
  private unsupportedEventBreakIds;
50
42
  /** Wallclock instant content playback ended (spec `event: 'end'`), or null while content plays. */
51
43
  private playbackEndedAtMs;
44
+ private readonly contentEnd;
52
45
  private debug;
53
46
  private readonly chain;
54
47
  private tuneInEnabled;
@@ -106,33 +99,18 @@ export declare class BreakScheduler {
106
99
  updateManifest(manifest: BreakManifest): void;
107
100
  applyEventBoundary(manifest: BreakManifest): void;
108
101
  setEventBoundaryCheck(check: ((positionMs: number | null) => boolean) | null): void;
109
- /**
110
- * Re-align the schedule to a manifest from a different channel (see `detectChannelSwitch`).
111
- * Every ledger of the previous channel is dropped so a break id reused by the new channel
112
- * is not suppressed as "already completed". An ACTIVE break is filtered from the new manifest
113
- * and ends as an early return. Before playback starts the new manifest's `adStartDelay` applies;
114
- * after it, the delay in effect is kept.
115
- */
116
102
  resetForChannelSwitch(manifest: BreakManifest): void;
117
103
  /**
118
104
  * `event: 'start'` and `event: 'end'` are scheduled here (pause breaks are filtered out before).
119
105
  * Unknown events are reported once and dropped, so they never fire as timeline breaks.
120
106
  */
121
107
  private isSchedulableEvent;
122
- /**
123
- * Content playback began (spec `event: 'start'`): pre-rolls may trigger and played time starts
124
- * counting. Once per session; a replay in the same session does not re-fire pre-rolls.
125
- */
126
108
  markPlaybackStart(): void;
127
109
  setOnAdStartDelayElapsed(callback: (() => void) | null): void;
128
110
  holdBreakStarts(): void;
129
111
  releaseBreakStarts(): void;
130
112
  areBreakStartsHeld(): boolean;
131
113
  private notifyAdStartDelayElapsed;
132
- /**
133
- * Content playback ended (spec `event: 'end'`): post-rolls may trigger once their `delay` has
134
- * elapsed on the wallclock. A completed post-roll never replays (see {@link completedBreakIds}).
135
- */
136
114
  markPlaybackEnd(): void;
137
115
  /** Start monitoring for break start times. */
138
116
  start(onBreakStart: BreakStartCallback, onBreakApproaching?: BreakApproachingCallback, onBreakWarning?: BreakWarningCallback, onBreakAborted?: BreakAbortedCallback, onBreakSkipped?: (brk: Break, reason: AdBreakSkipReason) => void): void;
@@ -158,14 +136,13 @@ export declare class BreakScheduler {
158
136
  setContentReloading(active: boolean): void;
159
137
  setStartupSeeking(active: boolean): void;
160
138
  /**
161
- * The runtime is about to resume-seek content to `targetSeconds` after a break ending at
162
- * `breakEndSeconds`. Arms a guard so the small backward step is not read as a DVR seek-back;
163
- * a genuine seek below the cue still re-arms. PTS timebase only (`seconds * 1000`).
139
+ * Arms resume-seek protection before runtime seeks content. Stale samples are ignored
140
+ * until landing, timeout or a viewer seek; the rewind guard also handles PTS DVR seek-back.
164
141
  */
165
142
  notifyResumeSeek(targetSeconds: number, breakEndSeconds: number): void;
166
143
  /** A viewer seek through the SDK: drop the resume-seek guard so the step is judged by the normal rules. */
167
144
  notifyViewerSeek(): void;
168
- setSnapbackOnSeek(enabled: boolean, canPlay?: (brk: Break) => boolean): void;
145
+ setSnapbackOnSeek(enabled: boolean, canPlay?: (brk: Break) => boolean, selectFormat?: (brk: Break) => BreakFormat | undefined): void;
169
146
  setOnSnapback(callback: ((request: SnapbackRequest) => void) | null): void;
170
147
  planSeek(target: number): SnapbackRequest | null;
171
148
  notifySeeked(): void;
@@ -183,17 +160,13 @@ export declare class BreakScheduler {
183
160
  /** Check the schedule on cue presentation. Returns the break armed by this cue, or null. */
184
161
  notifyCuePresented(pts: number): string | null;
185
162
  private checkBreaks;
186
- private checkPreRollBreak;
187
- private checkPostRollBreak;
163
+ private checkPlaybackEventBreak;
188
164
  isAdStartDelayActive(): boolean;
189
165
  private getCurrentTime;
190
166
  /** Retry rejected callbacks; an accepted preload fires at most once per break. */
191
167
  private maybePreload;
192
168
  private getBreakStartTime;
193
- /** Timebase position without PDT grace bookkeeping or a clock fallback. */
194
- private peekPosition;
195
169
  destroy(): void;
196
- /** Fire configured pre-break warnings, each threshold at most once and only while `secondsUntil > 0`. Pre-rolls excluded. */
197
170
  private checkWarnings;
198
171
  private log;
199
172
  }
@@ -1 +1 @@
1
- import{BreakEvent as e,Timebase as t,getBreakEvent as a,getPostRollDelay as i,getPreRollDelay as s,isEventTrigger as r,isPauseBreak as n}from"../types/BreakManifest";import{DEFAULT_BREAK_WARNINGS as h,DEFAULT_PDT_GRACE_SECONDS as d}from"../types/OptiViewAdsConfig";import{PTS_MATCH_TOLERANCE_SECONDS as l}from"./MetadataCueTracker";import{debugLog as o}from"../diagnostics/debugLog";import{createBreakTimingState as k,describeBreakStart as c,diagnoseAnvatoBreakEnd as u,getBreakStartTime as b,getCurrentTime as g,secondsUntilBreak as p}from"./breakTiming";import{BreakPreloadLedger as m}from"./BreakPreloadLedger";import{checkEventBreak as S}from"./checkEventBreak";import{SchedulerSnapback as B}from"./SchedulerSnapback";import{skipBreaksStartedBy as f,soonestBreak as E}from"./ssbStream";import{RewindRearm as I}from"./RewindRearm";import{BreakChain as y}from"./BreakChain";import{BreakState as D,systemClock as A}from"./BreakSchedulerTypes";export{BreakState,systemClock}from"./BreakSchedulerTypes";export const TICK_INTERVAL_MS=250;export class BreakScheduler{constructor(e,t=!1,a={enabled:!1,maxGapSeconds:0},i=A,s={enabled:!1,minBreakDurationSeconds:0},r=null,n=h,l=null,o=d){this.breaks=new Map,this.timing=k(),this.checkInterval=null,this.startDeadline=null,this.nextStartDeadlineMs=null,this.onBreakStart=null,this.onBreakApproaching=null,this.onBreakWarning=null,this.onBreakAborted=null,this.onBreakSkipped=null,this.beforeBreakCheck=null,this.breakStartsHeld=!1,this.preloadLedger=new m,this.onAdStartDelayElapsed=null,this.adStartDelayElapsedNotified=!1,this.abortNotifiedBreakIds=new Set,this.breakWarnings=h,this.breakWarningFired=new Map,this.completedBreakIds=new Set,this.unsupportedEventBreakIds=new Set,this.playbackEndedAtMs=null,this.anvatoEnteredDiagnosed=new Set,this.retiredBreaks=new Map,this.minSeenTime=null,this.JOIN_DISCRIMINATOR_SLACK_MS=1e3,this.WINDOW_ESCAPE_GRACE_MS=12e3,this.TIMING_TOLERANCE_MS=5e3,this.PRELOAD_AHEAD_SECONDS=8,this.contentReloading=!1,this.startupSeeking=!1,this.player=e,this.debug=t,this.chain=new y({breaks:this.breaks,enabled:a.enabled,maxGapMs:1e3*Math.max(0,a.maxGapSeconds),toleranceMs:this.TIMING_TOLERANCE_MS,getBreakStartTime:e=>this.getBreakStartTime(e),log:(...e)=>this.log(...e)}),this.tuneInEnabled=s.enabled,this.minBreakDurationMs=1e3*Math.max(0,s.minBreakDurationSeconds),this.clock=i,this.diagnose=r,this.snapback=new B({breaks:this.breaks,retiredBreaks:this.retiredBreaks,completedBreakIds:this.completedBreakIds,clock:i,diagnose:r,state:()=>({timebase:this.timing.timebase,started:this.rewind.playbackStarted,held:this.areBreakStartsHeld()||this.contentReloading||this.startupSeeking,active:null!==this.getActiveBreak(),position:this.player.currentTime}),preload:(e,t)=>this.maybePreload(e,t)}),this.breakWarnings=n,this.cueTracker=l;const c=Math.max(0,o);this.timingDeps={player:this.player,clock:this.clock,cueTracker:this.cueTracker,diagnose:this.diagnose,log:(...e)=>this.log(...e),pdtGraceSeconds:c,pdtGraceMs:1e3*c},this.rewind=new I({breaks:this.breaks,retiredBreaks:this.retiredBreaks,completedBreakIds:this.completedBreakIds,preloadLedger:this.preloadLedger,timing:this.timing,clock:this.clock,log:(...e)=>this.log(...e),getBreakStartTime:e=>this.getBreakStartTime(e)})}claimChain(e){this.chain.claim(e)}getChainedSuccessor(e){return this.chain.successorOf(e)}activateChainedBreak(e){this.chain.activate(e),this.snapback.guard.markPlayed(e)}setOnChainSuccessorChanged(e){this.chain.setOnSuccessorChanged(e)}updateManifest(e){this.timing.timebase=e.timebase;const t=this.rewind.adStartDelayMs;this.rewind.setAdStartDelayMs(1e3*Math.max(0,e.adStartDelay??0)),t>0&&0===this.rewind.adStartDelayMs&&this.notifyAdStartDelayElapsed();for(const t of e.breaks){if(n(t))continue;if(!this.isSchedulableEvent(t))continue;if(this.completedBreakIds.has(t.id))continue;const e=this.breaks.get(t.id);e?e.state!==D.PENDING&&e.state!==D.CHAINED||(e.break=t):(this.breaks.set(t.id,{break:t,state:D.PENDING}),this.log(`📅 Break scheduled: "${t.id}" at ${c(t)}`))}const a=new Set(e.breaks.map(e=>e.id));for(const[e,t]of this.breaks)a.has(e)||t.state!==D.ACTIVE||this.abortNotifiedBreakIds.has(e)||(this.abortNotifiedBreakIds.add(e),this.log("Break removed from manifest while active (early return):",e),this.diagnose?.("DA-BREAK-EARLY-RETURN","Active break removed from manifest; returning to content early",{breakId:e}),this.onBreakAborted?.(t.break));this.cleanupBreaks(a);const i=this.getActiveBreak();i&&!this.abortNotifiedBreakIds.has(i.id)&&this.chain.refresh(i)}applyEventBoundary(e){const t=this.rewind.playbackStarted;this.updateManifest(t?{...e,adStartDelay:0}:e),t&&this.rewind.ignoreAdStartDelay()}setEventBoundaryCheck(e){this.beforeBreakCheck=e}resetForChannelSwitch(e){this.snapback.guard.reset();const t=this.getActiveBreak();for(const e of[...this.breaks.keys()])e!==t?.id&&this.breaks.delete(e);for(const e of[...this.abortNotifiedBreakIds])e!==t?.id&&this.abortNotifiedBreakIds.delete(e);this.completedBreakIds.clear(),this.retiredBreaks.clear(),this.preloadLedger.clear(),this.breakWarningFired.clear(),this.unsupportedEventBreakIds.clear(),this.anvatoEnteredDiagnosed.clear(),this.timing.anvatoPendingDiagnosed.clear(),this.timing.anvatoMatchedDiagnosed.clear(),this.timing.anvatoEndDiagnosed.clear(),this.playbackEndedAtMs=null,this.rewind.beginChannel();const a=t?{...e,breaks:e.breaks.filter(e=>e.id!==t.id)}:e;this.updateManifest(a)}isSchedulableEvent(t){return!r(t.start)||t.start.event===e.START||t.start.event===e.END||(this.unsupportedEventBreakIds.has(t.id)||(this.unsupportedEventBreakIds.add(t.id),this.log(`⚠️ Break ignored: "${t.id}" (unsupported event trigger "${t.start.event}")`),this.diagnose?.("DA-BREAK-EVENT-UNSUPPORTED","Break triggered by an event this SDK version does not schedule; the break was ignored",{breakId:t.id,event:t.start.event})),!1)}markPlaybackStart(){null!==this.playbackEndedAtMs&&(this.playbackEndedAtMs=null,this.log("Content replayed; pending post-rolls cancelled")),this.rewind.playbackStarted||(this.rewind.markPlaybackStart(this.peekPosition()),this.log("Content playback started at",new Date(this.clock.now()).toISOString()))}setOnAdStartDelayElapsed(e){this.onAdStartDelayElapsed=e}holdBreakStarts(){this.breakStartsHeld=!0}releaseBreakStarts(){const e=this.breakStartsHeld;this.breakStartsHeld=!1,this.preloadLedger.checkAtBreakStart(e,()=>this.checkBreaks())}areBreakStartsHeld(){return this.breakStartsHeld||this.rewind.isAdStartDelayActive()}notifyAdStartDelayElapsed(){this.adStartDelayElapsedNotified||(this.adStartDelayElapsedNotified=!0,this.onAdStartDelayElapsed?.())}markPlaybackEnd(){null===this.playbackEndedAtMs&&(this.playbackEndedAtMs=this.clock.now(),this.log("Content playback ended at",new Date(this.playbackEndedAtMs).toISOString()))}start(e,t,a,i,s){this.onBreakStart=e,this.onBreakApproaching=t??null,this.onBreakWarning=a??null,this.onBreakAborted=i??null,this.onBreakSkipped=s??null,this.checkInterval&&clearInterval(this.checkInterval),this.checkInterval=setInterval(()=>{this.tick()},250),this.log("Scheduler started")}tick(){!1!==this.beforeBreakCheck?.(this.getCurrentTime())&&(this.checkBreaks(),null!==this.nextStartDeadlineMs&&this.armStartDeadline(this.nextStartDeadlineMs))}armStartDeadline(e){null!==this.checkInterval&&null===this.startDeadline&&(this.startDeadline=setTimeout(()=>{this.startDeadline=null,this.tick()},Math.max(1,Math.ceil(e))))}clearStartDeadline(){this.startDeadline&&clearTimeout(this.startDeadline),this.startDeadline=null}suspend(){this.checkInterval&&clearInterval(this.checkInterval),this.checkInterval=null,this.clearStartDeadline()}resume(){!this.checkInterval&&this.onBreakStart&&(this.checkInterval=setInterval(()=>this.tick(),250))}skipPendingBreaksDueBy(e){return f(this.getCurrentTime(),[...this.breaks.values()],e=>e.state===D.PENDING?this.getBreakStartTime(e.break):null,t=>this.skipBreak(t,e))}stop(){this.checkInterval&&(clearInterval(this.checkInterval),this.checkInterval=null),this.clearStartDeadline(),this.onBreakStart=null,this.onBreakSkipped=null,this.log("Scheduler stopped")}completeBreak(e){this.completedBreakIds.add(e),this.breakWarningFired.delete(e),this.abortNotifiedBreakIds.delete(e);const t=this.breaks.get(e);t&&(this.log("Break completed and removed:",e),null===a(t.break)&&null===this.cueTracker&&this.retiredBreaks.set(e,t.break),this.breaks.delete(e),this.preloadLedger.delete(e))}setContentReloading(e){this.snapback.guard.resetPosition(),this.contentReloading=e,this.rewind.setContentReloading(e||this.startupSeeking)}setStartupSeeking(e){this.snapback.guard.resetPosition(),this.startupSeeking=e,this.rewind.setContentReloading(e||this.contentReloading)}notifyResumeSeek(e,t){this.snapback.guard.expectSeek(e,this.clock.now()),this.rewind.notifyResumeSeek(e,t)}notifyViewerSeek(){this.rewind.notifyViewerSeek()}setSnapbackOnSeek(e,t){this.snapback.enabled=e,this.snapback.canPlay=t}setOnSnapback(e){this.snapback.onCorrect=e}planSeek(e){return this.snapback.planSeek(e)}notifySeeked(){this.snapback.notifySeeked()}startBreak(e,t){this.snapback.guard.markPlayed(e.id),this.onBreakStart?.(e,t)}skipBreak(e,t){e.state=D.SKIPPED,this.completedBreakIds.add(e.break.id),this.onBreakSkipped?.(e.break,t)}cleanupBreaks(e){const t=[];for(const[a,i]of this.breaks)i.state!==D.COMPLETED&&i.state!==D.SKIPPED?e.has(a)||i.state===D.ACTIVE||(this.skipBreak(i,"removed"),t.push(a)):t.push(a);for(const e of t)this.breaks.delete(e),this.preloadLedger.delete(e),this.breakWarningFired.delete(e)}getActiveBreak(){for(const e of this.breaks.values())if(e.state===D.ACTIVE)return e.break;return null}getSecondsUntilBreak(e){const t=this.breaks.get(e);if(!t||t.state!==D.PENDING)return null;const a=this.getCurrentTime();if(null===a)return null;const i=this.getBreakStartTime(t.break);return null===i?null:p(i,a)}getNextPendingBreak(){return E([...this.breaks.keys()],e=>this.getSecondsUntilBreak(e))}notifyCuePresented(e){if(null===this.cueTracker||this.timing.timebase!==t.PTS)return null;if(!Number.isFinite(e))return null;let a=null;for(const t of this.breaks.values())if(t.state===D.PENDING&&"number"==typeof t.break.start&&!(Math.abs(t.break.start-e)>l)){a=t.break.id;break}if(null===a)return null;const i=this.player.currentTime;this.checkBreaks();const s=this.breaks.get(a);return s&&s.state===D.ACTIVE?this.anvatoEnteredDiagnosed.has(a)?null:(this.anvatoEnteredDiagnosed.add(a),this.log(`🎯 Break "${a}" armed by cue presentation (pts ${e})`),this.diagnose?.("DA-ANVATO-CUE-ENTERED",`Break "${a}" started from the presented cue (pts ${e}) rather than the scheduler tick.`,{breakId:a,pts:e,mediaTimeSec:i}),a):null}checkBreaks(){if(this.nextStartDeadlineMs=null,this.startupSeeking)return;const e=this.getCurrentTime();this.rewind.trackPlayedTime(e),this.rewind.adStartDelayMs>0&&!this.rewind.isAdStartDelayActive()&&this.notifyAdStartDelayElapsed();const a=this.areBreakStartsHeld(),r=this.snapback.sample();if(!r.waiting){if(r.ready){const e=this.breaks.get(r.ready.breakId);if(e)return e.state=D.ACTIVE,void this.startBreak(e.break,{tuneIn:!1,elapsedSec:0,remainingSec:e.break.duration,snapback:void 0===r.ready.resumePosition?{}:{resumePosition:r.ready.resumePosition}})}null!==e&&(this.minSeenTime=null===this.minSeenTime?e:Math.min(this.minSeenTime,e),r.internal||this.rewind.rearmOnRewind(e));for(const r of this.breaks.values()){if(r.state===D.ACTIVE&&this.timing.timebase===t.WALLCLOCK&&null!==e&&!this.abortNotifiedBreakIds.has(r.break.id)){const t=this.getBreakStartTime(r.break),a=1e3*(r.break.duration??0);null!==t&&a>0&&e>t+a+this.WINDOW_ESCAPE_GRACE_MS&&(this.abortNotifiedBreakIds.add(r.break.id),this.log(`⚠️ Break window escaped: "${r.break.id}" (ended ${Math.round((e-t-a)/1e3)}s ago on the stream clock)`),this.diagnose?.("DA-BREAK-WINDOW-ESCAPED","Active break outlived its wallclock window; returning to content",{breakId:r.break.id,overrunSec:Math.round((e-t-a)/100)/10}),this.onBreakAborted?.(r.break));continue}if(r.state===D.ACTIVE&&this.timing.timebase===t.PTS&&u(this.timing,this.timingDeps,r.break),r.state!==D.PENDING)continue;const n=s(r.break);if(null!==n){this.checkPreRollBreak(r,n,a);continue}const h=i(r.break);if(null!==h){this.checkPostRollBreak(r,h,a);continue}if(null===e)continue;if(a)continue;null!==this.cueTracker&&this.timing.timebase===t.PTS&&this.maybePreload(r.break,"manifest arrival — Anvato flow");const d=this.getBreakStartTime(r.break);if(null===d)continue;const l=e-d,o=Math.round(-l/1e3);if(this.timing.timebase===t.WALLCLOCK&&l<0&&-l<=250&&(this.nextStartDeadlineMs=Math.min(this.nextStartDeadlineMs??1/0,-l)),this.checkWarnings(r.break,o),o>0&&o<=15&&o%2==0&&this.log(`⏱️ Break "${r.break.id}" starts in ${o}s`),o>0&&o<=this.PRELOAD_AHEAD_SECONDS&&this.maybePreload(r.break,`${o}s ahead`),l>=0&&l<=this.TIMING_TOLERANCE_MS){const e=Math.max(0,l),t=1e3*(r.break.duration??0)-e;if(t<=0){this.log(`⚠️ Break missed: "${r.break.id}" (ended ${Math.round(-t/1e3)}s ago, inside the start tolerance)`),this.skipBreak(r,t>0&&this.tuneInEnabled?"insufficient-remaining":"late"),null===this.cueTracker&&this.breaks.get(r.break.id)===r&&this.retiredBreaks.set(r.break.id,r.break);break}if(null!==this.minSeenTime&&d<this.minSeenTime-this.JOIN_DISCRIMINATOR_SLACK_MS&&(!this.tuneInEnabled||t<this.minBreakDurationMs)){this.log(`⚠️ Break missed: "${r.break.id}" (joined in progress inside the start tolerance, ${Math.round(t/1e3)}s remaining < tune-in rules)`),this.skipBreak(r,t>0&&this.tuneInEnabled?"insufficient-remaining":"late"),null===this.cueTracker&&this.breaks.get(r.break.id)===r&&this.retiredBreaks.set(r.break.id,r.break);break}this.preloadLedger.shouldRetryRejectedAtStart(r.break.id)&&this.maybePreload(r.break,"retrying a rejected preload at break start"),this.log(`🎬 BREAK TRIGGERED: "${r.break.id}"`),r.state=D.ACTIVE,this.onBreakStart&&this.startBreak(r.break,{tuneIn:!1,elapsedSec:e/1e3,remainingSec:t/1e3});break}if(l>this.TIMING_TOLERANCE_MS){const t=d+1e3*(r.break.duration??0)-e;if(this.tuneInEnabled&&t>=this.minBreakDurationMs&&t>0){this.log(`🎬 TUNE-IN BREAK TRIGGERED: "${r.break.id}" (${Math.round(t/1e3)}s remaining)`),r.state=D.ACTIVE,this.onBreakStart&&this.startBreak(r.break,{tuneIn:!0,elapsedSec:l/1e3,remainingSec:t/1e3});break}this.log(`⚠️ Break missed: "${r.break.id}" (was ${Math.round(l/1e3)}s ago)`),this.skipBreak(r,t>0&&this.tuneInEnabled?"insufficient-remaining":"late"),null===this.cueTracker&&this.breaks.get(r.break.id)===r&&this.retiredBreaks.set(r.break.id,r.break)}}}}checkPreRollBreak(e,t,a){S(e,t,this.rewind.playbackStarted?this.rewind.channelPlayedMs:null,this.rewind.adStartDelayMs,a,(e,t)=>this.maybePreload(e,t),e=>this.skipBreak(e,"start-delay"),(e,t)=>this.startBreak(e,t))}checkPostRollBreak(e,t,a){S(e,t,null===this.playbackEndedAtMs?null:this.clock.now()-this.playbackEndedAtMs,0,a,(e,t)=>this.maybePreload(e,t),e=>this.skipBreak(e,"start-delay"),(e,t)=>this.startBreak(e,t))}isAdStartDelayActive(){return this.areBreakStartsHeld()}getCurrentTime(){return g(this.timing,this.timingDeps)}maybePreload(e,t){if(this.preloadLedger.wasPreloaded(e.id)||!this.onBreakApproaching)return;this.log(`⏳ Preloading break "${e.id}" (${t})`);const a=!1!==this.onBreakApproaching(e);this.preloadLedger.recordAttempt(e.id,a)}getBreakStartTime(e){return r(e.start)?null:b(this.timing,this.timingDeps,e)}peekPosition(){if(this.timing.timebase!==t.WALLCLOCK)return 1e3*this.player.currentTime;const e=this.player.programDateTime?.getTime();return void 0===e||isNaN(e)?null:e}destroy(){this.snapback.guard.reset(),this.snapback.onCorrect=null,this.stop(),this.breaks.clear(),this.preloadLedger.clear(),this.completedBreakIds.clear(),this.retiredBreaks.clear(),this.minSeenTime=null,this.breakStartsHeld=!1,this.onAdStartDelayElapsed=null,this.adStartDelayElapsedNotified=!1,this.unsupportedEventBreakIds.clear(),this.playbackEndedAtMs=null,this.rewind.reset(),this.breakWarningFired.clear(),this.abortNotifiedBreakIds.clear(),this.timing.pdtMissingWarned=!1,this.timing.pdtSeen=!1,this.timing.firstPdtAttemptMs=null,this.timing.playbackStartedAtMs=null,this.timing.anvatoPendingDiagnosed.clear(),this.timing.anvatoMatchedDiagnosed.clear(),this.timing.anvatoEndDiagnosed.clear(),this.cueTracker?.clear()}checkWarnings(e,t){if(t<=0||0===this.breakWarnings.seconds.length)return;let a=this.breakWarningFired.get(e.id);a||(a=new Set,this.breakWarningFired.set(e.id,a));for(const i of this.breakWarnings.seconds)a.has(i)||t<=i&&(a.add(i),this.log(`Break warning for "${e.id}" (${t}s <= ${i}s)`),this.onBreakWarning?.(e,i,t))}log(...e){this.debug&&o("[OptiViewAds:BreakScheduler]",...e)}}
1
+ import{ContentEndTracker as e}from"./ContentEndTracker";import{BreakWarningTracker as t}from"./BreakWarningTracker";import{BreakEvent as a,Timebase as i,getBreakEvent as s,getPostRollDelay as r,getPreRollDelay as n,isEventTrigger as h,isPauseBreak as d}from"../types/BreakManifest";import{DEFAULT_BREAK_WARNINGS as l,DEFAULT_PDT_GRACE_SECONDS as k}from"../types/OptiViewAdsConfig";import{PTS_MATCH_TOLERANCE_SECONDS as o}from"./MetadataCueTracker";import{debugLog as c}from"../diagnostics/debugLog";import{createBreakTimingState as u,describeBreakStart as b,diagnoseAnvatoBreakEnd as p,getBreakStartTime as g,getCurrentTime as m,peekPosition as S,secondsUntilBreak as B}from"./breakTiming";import{BreakPreloadLedger as E}from"./BreakPreloadLedger";import{checkEventBreak as f}from"./checkEventBreak";import{SchedulerSnapback as y}from"./SchedulerSnapback";import{skipBreaksStartedBy as I,soonestBreak as A}from"./ssbStream";import{RewindRearm as D}from"./RewindRearm";import{BreakChain as T}from"./BreakChain";import{BreakState as v,systemClock as M}from"./BreakSchedulerTypes";export{BreakState,systemClock}from"./BreakSchedulerTypes";export const TICK_INTERVAL_MS=250;export class BreakScheduler{constructor(a,s=!1,r={enabled:!1,maxGapSeconds:0},n=M,d={enabled:!1,minBreakDurationSeconds:0},o=null,c=l,b=null,p=k){this.breaks=new Map,this.timing=u(),this.checkInterval=null,this.startDeadline=null,this.nextStartDeadlineMs=null,this.onBreakStart=null,this.onBreakApproaching=null,this.onBreakWarning=null,this.onBreakAborted=null,this.onBreakSkipped=null,this.beforeBreakCheck=null,this.breakStartsHeld=!1,this.preloadLedger=new E,this.onAdStartDelayElapsed=null,this.adStartDelayElapsedNotified=!1,this.abortNotifiedBreakIds=new Set,this.breakWarnings=l,this.breakWarningFired=new t,this.completedBreakIds=new Set,this.unsupportedEventBreakIds=new Set,this.playbackEndedAtMs=null,this.anvatoEnteredDiagnosed=new Set,this.retiredBreaks=new Map,this.minSeenTime=null,this.JOIN_DISCRIMINATOR_SLACK_MS=1e3,this.WINDOW_ESCAPE_GRACE_MS=12e3,this.TIMING_TOLERANCE_MS=5e3,this.PRELOAD_AHEAD_SECONDS=8,this.contentReloading=!1,this.startupSeeking=!1,this.getBreakStartTime=e=>h(e.start)?null:g(this.timing,this.timingDeps,e),this.player=a,this.debug=s,this.chain=new T({breaks:this.breaks,enabled:r.enabled,maxGapMs:1e3*Math.max(0,r.maxGapSeconds),toleranceMs:this.TIMING_TOLERANCE_MS,getBreakStartTime:e=>this.timing.timebase===i.MEDIATIME&&this.snapback.guard.isSkipped(e.id)?null:this.getBreakStartTime(e),log:(...e)=>this.log(...e)}),this.tuneInEnabled=d.enabled,this.minBreakDurationMs=1e3*Math.max(0,d.minBreakDurationSeconds),this.clock=n,this.diagnose=o,this.contentEnd=new e(a,n,o),this.snapback=new y({breaks:this.breaks,retiredBreaks:this.retiredBreaks,completedBreakIds:this.completedBreakIds,clock:n,diagnose:o,state:()=>({timebase:this.timing.timebase,started:this.rewind.playbackStarted,held:this.areBreakStartsHeld()||this.contentReloading||this.startupSeeking,active:null!==this.getActiveBreak(),position:this.player.currentTime}),preload:(e,t)=>this.maybePreload(e,t),skip:e=>this.skipBreak(e,"late")}),this.breakWarnings=c,this.cueTracker=b;const m=Math.max(0,p);this.timingDeps={player:this.player,clock:this.clock,cueTracker:this.cueTracker,diagnose:this.diagnose,log:(...e)=>this.log(...e),pdtGraceSeconds:m,pdtGraceMs:1e3*m},this.rewind=new D({breaks:this.breaks,retiredBreaks:this.retiredBreaks,completedBreakIds:this.completedBreakIds,preloadLedger:this.preloadLedger,timing:this.timing,clock:this.clock,log:(...e)=>this.log(...e),getBreakStartTime:e=>this.getBreakStartTime(e)})}claimChain(e){this.chain.claim(e)}getChainedSuccessor(e){return this.chain.successorOf(e)}activateChainedBreak(e){this.chain.activate(e),this.snapback.guard.markPlayed(e)}setOnChainSuccessorChanged(e){this.chain.setOnSuccessorChanged(e)}updateManifest(e){this.timing.timebase=e.timebase;const t=this.rewind.adStartDelayMs;this.rewind.setAdStartDelayMs(1e3*Math.max(0,e.adStartDelay??0)),t>0&&0===this.rewind.adStartDelayMs&&this.notifyAdStartDelayElapsed();for(const t of e.breaks){if(d(t))continue;if(!this.isSchedulableEvent(t))continue;if(this.completedBreakIds.has(t.id))continue;const e=this.breaks.get(t.id);e?e.state!==v.PENDING&&e.state!==v.CHAINED||(e.break=t):(this.breaks.set(t.id,{break:t,state:v.PENDING}),this.log(`📅 Break scheduled: "${t.id}" at ${b(t)}`))}const a=new Set(e.breaks.map(e=>e.id));for(const[e,t]of this.breaks)a.has(e)||t.state!==v.ACTIVE||this.abortNotifiedBreakIds.has(e)||(this.abortNotifiedBreakIds.add(e),this.log("Break removed from manifest while active (early return):",e),this.diagnose?.("DA-BREAK-EARLY-RETURN","Active break removed from manifest; returning to content early",{breakId:e}),this.onBreakAborted?.(t.break));this.cleanupBreaks(a);const i=this.getActiveBreak();i&&!this.abortNotifiedBreakIds.has(i.id)&&this.chain.refresh(i),this.scheduleObserver?.update(e)}applyEventBoundary(e){const t=this.rewind.playbackStarted;this.updateManifest(t?{...e,adStartDelay:0}:e),t&&this.rewind.ignoreAdStartDelay()}setEventBoundaryCheck(e){this.beforeBreakCheck=e}resetForChannelSwitch(e){this.snapback.guard.reset();const t=this.getActiveBreak();for(const e of[...this.breaks.keys()])e!==t?.id&&this.breaks.delete(e);for(const e of[...this.abortNotifiedBreakIds])e!==t?.id&&this.abortNotifiedBreakIds.delete(e);this.completedBreakIds.clear(),this.retiredBreaks.clear(),this.preloadLedger.clear(),this.breakWarningFired.clear(),this.unsupportedEventBreakIds.clear(),this.anvatoEnteredDiagnosed.clear(),this.timing.anvatoPendingDiagnosed.clear(),this.timing.anvatoMatchedDiagnosed.clear(),this.timing.anvatoEndDiagnosed.clear(),this.playbackEndedAtMs=null,this.contentEnd.resetSample(),this.rewind.beginChannel();const a=t?{...e,breaks:e.breaks.filter(e=>e.id!==t.id)}:e;this.scheduleObserver?.resetForChannelSwitch(a),this.updateManifest(a)}isSchedulableEvent(e){return!h(e.start)||e.start.event===a.START||e.start.event===a.END||(this.unsupportedEventBreakIds.has(e.id)||(this.unsupportedEventBreakIds.add(e.id),this.log(`⚠️ Break ignored: "${e.id}" (unsupported event trigger "${e.start.event}")`),this.diagnose?.("DA-BREAK-EVENT-UNSUPPORTED","Break triggered by an event this SDK version does not schedule; the break was ignored",{breakId:e.id,event:e.start.event})),!1)}markPlaybackStart(){null!==this.playbackEndedAtMs&&this.contentEnd.isReplay(this.player.currentTime,!0)&&(this.playbackEndedAtMs=null,this.contentEnd.resetSample(),this.log("Content replayed; pending post-rolls cancelled")),this.rewind.playbackStarted||(this.snapback.guard.captureStart(this.player.currentTime,this.timing.timebase),this.rewind.markPlaybackStart(S(this.timing,this.player)),this.log("Content playback started at",new Date(this.clock.now()).toISOString()))}setOnAdStartDelayElapsed(e){this.onAdStartDelayElapsed=e}holdBreakStarts(){this.breakStartsHeld=!0}releaseBreakStarts(){const e=this.breakStartsHeld;this.breakStartsHeld=!1,this.preloadLedger.checkAtBreakStart(e,()=>this.checkBreaks())}areBreakStartsHeld(){return this.breakStartsHeld||this.rewind.isAdStartDelayActive()}notifyAdStartDelayElapsed(){this.adStartDelayElapsedNotified||(this.adStartDelayElapsedNotified=!0,this.onAdStartDelayElapsed?.())}markPlaybackEnd(){null===this.playbackEndedAtMs&&(this.playbackEndedAtMs=this.clock.now(),this.contentEnd.mark(this.player.currentTime),this.log("Content playback ended at",new Date(this.playbackEndedAtMs).toISOString()))}start(e,t,a,i,s){this.onBreakStart=e,this.onBreakApproaching=t??null,this.onBreakWarning=a??null,this.onBreakAborted=i??null,this.onBreakSkipped=s??null,this.checkInterval&&clearInterval(this.checkInterval),this.checkInterval=setInterval(()=>{this.tick()},250),this.log("Scheduler started")}tick(){this.scheduleObserver?.refresh(),!1!==this.beforeBreakCheck?.(this.getCurrentTime())&&(this.checkBreaks(),null!==this.nextStartDeadlineMs&&this.armStartDeadline(this.nextStartDeadlineMs))}armStartDeadline(e){null!==this.checkInterval&&null===this.startDeadline&&(this.startDeadline=setTimeout(()=>{this.startDeadline=null,this.tick()},Math.max(1,Math.ceil(e))))}clearStartDeadline(){this.startDeadline&&clearTimeout(this.startDeadline),this.startDeadline=null}suspend(){this.checkInterval&&clearInterval(this.checkInterval),this.checkInterval=null,this.clearStartDeadline()}resume(){!this.checkInterval&&this.onBreakStart&&(this.checkInterval=setInterval(()=>this.tick(),250))}skipPendingBreaksDueBy(e){return I(this.getCurrentTime(),[...this.breaks.values()],e=>e.state===v.PENDING?this.getBreakStartTime(e.break):null,t=>this.skipBreak(t,e))}stop(){this.checkInterval&&(clearInterval(this.checkInterval),this.checkInterval=null),this.clearStartDeadline(),this.onBreakStart=null,this.onBreakSkipped=null,this.log("Scheduler stopped")}completeBreak(e){this.completedBreakIds.add(e),this.breakWarningFired.delete(e),this.abortNotifiedBreakIds.delete(e);const t=this.breaks.get(e);t&&(this.log("Break completed and removed:",e),null===s(t.break)&&null===this.cueTracker&&this.retiredBreaks.set(e,t.break),this.breaks.delete(e),this.preloadLedger.delete(e))}setContentReloading(e){this.contentEnd.resetSample(),this.snapback.guard.resetPosition(),this.contentReloading=e,this.rewind.setContentReloading(e||this.startupSeeking)}setStartupSeeking(e){this.contentEnd.resetSample(),this.snapback.guard.resetPosition(),this.startupSeeking=e,this.rewind.setContentReloading(e||this.contentReloading)}notifyResumeSeek(e,t){this.snapback.guard.expectSeek(e,this.clock.now(),!0),this.rewind.notifyResumeSeek(e,t)}notifyViewerSeek(){this.snapback.guard.cancelResumeSeek(),this.snapback.guard.noteViewerSeek(),this.rewind.notifyViewerSeek()}setSnapbackOnSeek(e,t,a){this.snapback.enabled=e,this.snapback.canPlay=t,this.snapback.guard.selectFormat=a}setOnSnapback(e){this.snapback.onCorrect=e}planSeek(e){return this.snapback.planSeek(e)}notifySeeked(){this.snapback.notifySeeked()}startBreak(e,t){this.snapback.guard.markPlayed(e.id),this.onBreakStart?.(e,t)}skipBreak(e,t){e.state=v.SKIPPED,this.completedBreakIds.add(e.break.id),this.onBreakSkipped?.(e.break,t)}cleanupBreaks(e){const t=[];for(const[a,i]of this.breaks)i.state!==v.COMPLETED&&i.state!==v.SKIPPED?e.has(a)||i.state===v.ACTIVE||(this.skipBreak(i,"removed"),t.push(a)):t.push(a);for(const e of t)this.breaks.delete(e),this.preloadLedger.delete(e),this.breakWarningFired.delete(e)}getActiveBreak(){for(const e of this.breaks.values())if(e.state===v.ACTIVE)return e.break;return null}getSecondsUntilBreak(e){const t=this.breaks.get(e);if(!t||t.state!==v.PENDING)return null;const a=this.getCurrentTime();if(null===a)return null;const i=this.getBreakStartTime(t.break);return null===i?null:B(i,a)}getNextPendingBreak(){return A([...this.breaks.keys()],e=>this.getSecondsUntilBreak(e))}notifyCuePresented(e){if(null===this.cueTracker||this.timing.timebase!==i.PTS)return null;if(!Number.isFinite(e))return null;let t=null;for(const a of this.breaks.values())if(a.state===v.PENDING&&"number"==typeof a.break.start&&!(Math.abs(a.break.start-e)>o)){t=a.break.id;break}if(null===t)return null;const a=this.player.currentTime;this.checkBreaks();const s=this.breaks.get(t);return s&&s.state===v.ACTIVE?this.anvatoEnteredDiagnosed.has(t)?null:(this.anvatoEnteredDiagnosed.add(t),this.log(`🎯 Break "${t}" armed by cue presentation (pts ${e})`),this.diagnose?.("DA-ANVATO-CUE-ENTERED",`Break "${t}" started from the presented cue (pts ${e}) rather than the scheduler tick.`,{breakId:t,pts:e,mediaTimeSec:a}),t):null}checkBreaks(){if(this.nextStartDeadlineMs=null,this.contentEnd.check(null!==this.playbackEndedAtMs,!this.startupSeeking&&!this.contentReloading&&!this.getActiveBreak(),[...this.breaks.values()].some(e=>e.state===v.PENDING&&null!==r(e.break)),()=>this.markPlaybackStart(),()=>this.markPlaybackEnd()),this.startupSeeking)return;const e=this.getCurrentTime();this.rewind.trackPlayedTime(e),this.rewind.adStartDelayMs>0&&!this.rewind.isAdStartDelayActive()&&this.notifyAdStartDelayElapsed();const t=this.areBreakStartsHeld();this.rewind.playbackStarted&&this.snapback.guard.captureStart(this.player.currentTime,this.timing.timebase);const a=this.snapback.sample();if(a.waiting)return;if(a.ready){const e=this.breaks.get(a.ready.breakId);if(e)return e.state=v.ACTIVE,void this.startBreak(e.break,this.snapback.guard.startContext(a.ready,e.break.duration))}if(!this.snapback.guard.bookmark.check(this.breaks.values(),this.retiredBreaks,this.player.currentTime,t||this.contentReloading||null!==this.getActiveBreak(),e=>{this.scheduleObserver?.skipBookmark(e.break),this.skipBreak(e,"late")},(e,t)=>this.startBreak(e,t),this.diagnose)){null!==e&&(this.minSeenTime=null===this.minSeenTime?e:Math.min(this.minSeenTime,e),a.internal||this.rewind.rearmOnRewind(e));for(const a of this.breaks.values()){if(a.state===v.ACTIVE&&this.timing.timebase===i.WALLCLOCK&&null!==e&&!this.abortNotifiedBreakIds.has(a.break.id)){const t=this.getBreakStartTime(a.break),i=1e3*(a.break.duration??0);null!==t&&i>0&&e>t+i+this.WINDOW_ESCAPE_GRACE_MS&&(this.abortNotifiedBreakIds.add(a.break.id),this.log(`⚠️ Break window escaped: "${a.break.id}" (ended ${Math.round((e-t-i)/1e3)}s ago on the stream clock)`),this.diagnose?.("DA-BREAK-WINDOW-ESCAPED","Active break outlived its wallclock window; returning to content",{breakId:a.break.id,overrunSec:Math.round((e-t-i)/100)/10}),this.onBreakAborted?.(a.break));continue}if(a.state===v.ACTIVE&&this.timing.timebase===i.PTS&&p(this.timing,this.timingDeps,a.break),a.state!==v.PENDING)continue;const s=n(a.break);if(null!==s){this.checkPlaybackEventBreak(a,s,t,!0);continue}const h=r(a.break);if(null!==h){this.checkPlaybackEventBreak(a,h,t,!1);continue}if(null===e)continue;if(t)continue;null!==this.cueTracker&&this.timing.timebase===i.PTS&&this.maybePreload(a.break,"manifest arrival — Anvato flow");const d=this.getBreakStartTime(a.break);if(null===d)continue;const l=e-d,k=Math.round(-l/1e3);if(this.timing.timebase===i.WALLCLOCK&&l<0&&-l<=250&&(this.nextStartDeadlineMs=Math.min(this.nextStartDeadlineMs??1/0,-l)),this.checkWarnings(a.break,k),k>0&&k<=15&&k%2==0&&this.log(`⏱️ Break "${a.break.id}" starts in ${k}s`),k>0&&k<=this.PRELOAD_AHEAD_SECONDS&&this.maybePreload(a.break,`${k}s ahead`),l>=0&&l<=this.TIMING_TOLERANCE_MS){const e=Math.max(0,l),t=1e3*(a.break.duration??0)-e,i=this.snapback.guard.blocksSeekStart(a.break,this.timing.timebase);if(t<=0||i){this.log(`⚠️ Break missed: "${a.break.id}" (${i?"watched seek":"ended"} inside the start tolerance)`),this.skipBreak(a,"late"),null===this.cueTracker&&this.breaks.get(a.break.id)===a&&this.retiredBreaks.set(a.break.id,a.break);break}if(null!==this.minSeenTime&&d<this.minSeenTime-this.JOIN_DISCRIMINATOR_SLACK_MS&&(!this.tuneInEnabled||t<this.minBreakDurationMs)){this.log(`⚠️ Break missed: "${a.break.id}" (joined in progress inside the start tolerance, ${Math.round(t/1e3)}s remaining < tune-in rules)`),this.skipBreak(a,t>0&&this.tuneInEnabled?"insufficient-remaining":"late"),null===this.cueTracker&&this.breaks.get(a.break.id)===a&&this.retiredBreaks.set(a.break.id,a.break);break}this.preloadLedger.shouldRetryRejectedAtStart(a.break.id)&&this.maybePreload(a.break,"retrying a rejected preload at break start"),this.log(`🎬 BREAK TRIGGERED: "${a.break.id}"`),a.state=v.ACTIVE,this.onBreakStart&&this.startBreak(a.break,{tuneIn:!1,elapsedSec:e/1e3,remainingSec:t/1e3});break}if(l>this.TIMING_TOLERANCE_MS){const t=this.timing.timebase===i.MEDIATIME?0:d+1e3*(a.break.duration??0)-e,s=this.snapback.guard.blocksTuneIn(a.break,this.timing.timebase);if(!s&&this.tuneInEnabled&&t>=this.minBreakDurationMs&&t>0){this.log(`🎬 TUNE-IN BREAK TRIGGERED: "${a.break.id}" (${Math.round(t/1e3)}s remaining)`),a.state=v.ACTIVE,this.onBreakStart&&this.startBreak(a.break,{tuneIn:!0,elapsedSec:l/1e3,remainingSec:t/1e3});break}this.log(`⚠️ Break missed: "${a.break.id}" (was ${Math.round(l/1e3)}s ago)`),this.skipBreak(a,!s&&t>0&&this.tuneInEnabled?"insufficient-remaining":"late"),null===this.cueTracker&&this.breaks.get(a.break.id)===a&&this.retiredBreaks.set(a.break.id,a.break)}}}}checkPlaybackEventBreak(e,t,a,i){i||null!==this.playbackEndedAtMs||a||this.contentReloading||this.getActiveBreak()||!this.contentEnd.isPostRollApproaching(t,this.PRELOAD_AHEAD_SECONDS)||this.maybePreload(e.break,"post-roll approaching"),f(e,t,i?this.rewind.playbackStarted?this.rewind.channelPlayedMs:null:null===this.playbackEndedAtMs?null:this.clock.now()-this.playbackEndedAtMs,i?this.rewind.adStartDelayMs:0,a,(e,t)=>this.maybePreload(e,t),e=>this.skipBreak(e,"start-delay"),(e,t)=>this.startBreak(e,t))}isAdStartDelayActive(){return this.areBreakStartsHeld()}getCurrentTime(){return m(this.timing,this.timingDeps)}maybePreload(e,t){if(this.preloadLedger.wasPreloaded(e.id)||!this.onBreakApproaching)return;this.log(`⏳ Preloading break "${e.id}" (${t})`);const a=!1!==this.onBreakApproaching(e);this.preloadLedger.recordAttempt(e.id,a)}destroy(){this.snapback.guard.reset(),this.snapback.onCorrect=null,this.stop(),this.breaks.clear(),this.preloadLedger.clear(),this.completedBreakIds.clear(),this.retiredBreaks.clear(),this.minSeenTime=null,this.breakStartsHeld=!1,this.onAdStartDelayElapsed=null,this.adStartDelayElapsedNotified=!1,this.unsupportedEventBreakIds.clear(),this.playbackEndedAtMs=null,this.contentEnd.resetSample(),this.rewind.reset(),this.breakWarningFired.clear(),this.abortNotifiedBreakIds.clear(),this.timing.pdtMissingWarned=!1,this.timing.pdtSeen=!1,this.timing.firstPdtAttemptMs=null,this.timing.playbackStartedAtMs=null,this.timing.anvatoPendingDiagnosed.clear(),this.timing.anvatoMatchedDiagnosed.clear(),this.timing.anvatoEndDiagnosed.clear(),this.cueTracker?.clear()}checkWarnings(e,t){this.breakWarningFired.check(e,t,this.breakWarnings.seconds,(...e)=>this.log(...e),this.onBreakWarning)}log(...e){this.debug&&c("[OptiViewAds:BreakScheduler]",...e)}}
@@ -1,4 +1,4 @@
1
- import type { Break } from '../types/BreakManifest';
1
+ import type { Break, BreakManifest } from '../types/BreakManifest';
2
2
  /** Wallclock time source, so a driver (e.g. the conformance harness) can step time deterministically. */
3
3
  export interface Clock {
4
4
  /** Current wallclock time in milliseconds since the epoch. */
@@ -18,6 +18,12 @@ export declare enum BreakState {
18
18
  /** Break was skipped (e.g., already passed) */
19
19
  SKIPPED = "skipped"
20
20
  }
21
+ export interface ScheduledBreaksObserver {
22
+ resetForChannelSwitch(manifest: BreakManifest): void;
23
+ update(manifest: BreakManifest): void;
24
+ refresh(): void;
25
+ skipBookmark(brk: Break): void;
26
+ }
21
27
  /** Tune-in (join-in-progress) configuration. */
22
28
  export interface TuneInOptions {
23
29
  /** Whether tune-in breaks are detected and triggered. */
@@ -0,0 +1,6 @@
1
+ import type { Break } from '../types/BreakManifest';
2
+ import type { BreakWarningCallback } from './BreakSchedulerTypes';
3
+ export declare class BreakWarningTracker extends Map<string, Set<number>> {
4
+ private readonly fired;
5
+ check(breakInfo: Break, secondsUntil: number, seconds: number[], log: (...args: unknown[]) => void, onWarning: BreakWarningCallback | null): void;
6
+ }
@@ -0,0 +1 @@
1
+ export class BreakWarningTracker extends Map{constructor(){super(...arguments),this.fired=this}check(e,r,t,s,i){if(r<=0||0===t.length)return;let n=this.fired.get(e.id);n||(n=new Set,this.fired.set(e.id,n));for(const a of t)n.has(a)||r<=a&&(n.add(a),s(`Break warning for "${e.id}" (${r}s <= ${a}s)`),i?.(e,a,r))}}
@@ -0,0 +1,17 @@
1
+ import type { PlayerAdapter } from '../interfaces/PlayerAdapter';
2
+ import type { Clock, SchedulerDiagnose } from './BreakSchedulerTypes';
3
+ export declare class ContentEndTracker {
4
+ private readonly player;
5
+ private readonly clock;
6
+ private readonly diagnose;
7
+ private endPosition;
8
+ private samplePosition;
9
+ private sampleAt;
10
+ constructor(player: PlayerAdapter, clock: Clock, diagnose: SchedulerDiagnose | null);
11
+ mark(position: number): void;
12
+ isReplay(position: number, playbackStart?: boolean): boolean;
13
+ resetSample(): void;
14
+ isPostRollApproaching(delay: number, preloadAheadSeconds: number): boolean;
15
+ check(ended: boolean, stable: boolean, hasPendingEnd: boolean, onReplay: () => void, onEnd: () => void): void;
16
+ infer(player: PlayerAdapter, now: number, eligible: boolean): 'past-end' | 'stalled-at-end' | null;
17
+ }
@@ -0,0 +1 @@
1
+ export class ContentEndTracker{constructor(i,e,t){this.player=i,this.clock=e,this.diagnose=t,this.endPosition=NaN,this.samplePosition=NaN,this.sampleAt=0}mark(i){this.endPosition=i,this.resetSample()}isReplay(i,e=!1){return Number.isFinite(i)&&Number.isFinite(this.endPosition)?i<this.endPosition-1:e}resetSample(){this.samplePosition=NaN}isPostRollApproaching(i,e){const{currentTime:t,duration:s}=this.player,n=s-t+i;return!1===this.player.isLive&&Number.isFinite(s)&&s>0&&Number.isFinite(t)&&t>=0&&t<s&&n>0&&n<=e}check(i,e,t,s,n){const r=i&&e&&this.isReplay(this.player.currentTime);r&&s();const a=this.infer(this.player,this.clock.now(),(!i||r)&&e&&t);a&&(n(),this.diagnose?.("DA-CONTENT-END-INFERRED","Content playback end inferred from playhead",{currentTime:this.player.currentTime,duration:this.player.duration,trigger:a}))}infer(i,e,t){const{currentTime:s,duration:n,isLive:r,paused:a}=i;if(!t||!1!==r||!Number.isFinite(n)||n<=0||!Number.isFinite(s))return this.resetSample(),null;return s>=n-(a?.05:.25)?"past-end":a||s<n-1?(this.resetSample(),null):((!Number.isFinite(this.samplePosition)||Math.abs(s-this.samplePosition)>=.05)&&(this.samplePosition=s,this.sampleAt=e),e-this.sampleAt>=2e3?"stalled-at-end":null)}}
@@ -1,8 +1,8 @@
1
1
  import type { KnownDiagnosticCode } from '../diagnostics/errorCodes';
2
2
  import type { PlayerAdapter } from '../interfaces/PlayerAdapter';
3
3
  import { type ExternalPlaybackGuardInput } from './externalPlaybackGuard';
4
- import { type SsbSwitchPhase } from './ssbSwitchSignals';
5
- export type ExternalPlaybackAllowedTrigger = 'connecting' | 'active' | 'break-ended';
4
+ import { type SsbSwitchPhase, type SsbSwitchTrigger } from './ssbSwitchSignals';
5
+ export type ExternalPlaybackAllowedTrigger = SsbSwitchTrigger;
6
6
  export interface ExternalPlaybackGateDeps {
7
7
  player: PlayerAdapter;
8
8
  blockBeforeBreakSec: number;
@@ -2,7 +2,7 @@ import { BreakManifest } from '../types/BreakManifest';
2
2
  import { ManifestRequestInterceptor, ManifestResponseInterceptor } from '../types/OptiViewAdsConfig';
3
3
  import { ManifestSignatureKey } from './manifestSignature';
4
4
  /** Narrow diagnostic sink so the service does not depend on the full diagnostics module. */
5
- export type ManifestDiagnose = (code: 'DA-MANIFEST-INTERCEPT-FAILED' | 'DA-MANIFEST-REQUEST-INTERCEPT-FAILED' | 'DA-MANIFEST-SIGNATURE-INVALID' | 'DA-MANIFEST-EVENT-INVALID', message: string, context?: Record<string, unknown>) => void;
5
+ export type ManifestDiagnose = (code: 'DA-MANIFEST-INTERCEPT-FAILED' | 'DA-MANIFEST-REQUEST-INTERCEPT-FAILED' | 'DA-MANIFEST-SIGNATURE-INVALID' | 'DA-MANIFEST-EVENT-INVALID' | 'DA-SLATE-INVALID', message: string, context?: Record<string, unknown>) => void;
6
6
  /** Fetches, verifies, parses and polls break manifests. */
7
7
  /**
8
8
  * Whole-request budget for one manifest fetch. Matches the Android OkHttp `callTimeout` and
@@ -1 +1 @@
1
- import{fetchWithTimeout as t}from"./fetchWithTimeout";import{verifyManifestSignature as e,MANIFEST_SIGNATURE_HEADER as s}from"./manifestSignature";import{embeddedManifestKeys as i}from"./manifestTrustedKeys";import{parseBreakManifest as r}from"./parseBreakManifest";import{resolvePollingDecision as n}from"./resolvePollingDecision";import{debugLog as o}from"../diagnostics/debugLog";export const MANIFEST_FETCH_TIMEOUT_MS=15e3;export class ManifestService{get currentPollingIntervalSeconds(){return this.pollingIntervalSeconds}constructor(t=!1,e,s,r,n){this.manifestUrl=null,this.pollingInterval=null,this.pollingIntervalSeconds=null,this.pollCallbacks=null,this.currentManifest=null,this.isActive=!1,this.hostTally=new Map,this.reportedEventIssues=new Set,this.lastFailure=null,this.debug=t,this.interceptor=e,this.diagnose=s,this.requestInterceptor=r,this.trustedKeys=n??i()}takeLastFailure(){const t=this.lastFailure;return this.lastFailure=null,t}record(t,e,s,i){const r=function(t){try{return new URL(t).host}catch{return t}}(t),n=this.hostTally.get(r)??{ok:0,fail:0};if("ok"===e)return n.ok++,void this.hostTally.set(r,n);this.lastFailure={url:t,host:r,outcome:e,ms:s,hostOk:n.ok,hostFail:n.fail,...void 0===i?{}:{status:i}},n.fail++,this.hostTally.set(r,n)}setManifestUrl(t){this.manifestUrl=t,this.reportedEventIssues.clear()}async fetchManifest(){if(!this.manifestUrl)throw new Error("Manifest URL not set. Call setManifestUrl() before fetching.");this.log("Fetching manifest from:",this.manifestUrl);const t=await this.fetchManifestData(this.manifestUrl),e=this.parseManifest(t),s=await this.applyInterceptor(e);return this.currentManifest=s,this.log("Manifest loaded:",s.breaks.length,"breaks"),s}async fetchManifestData(i){const r={url:i};let n;if(this.requestInterceptor)try{n=await this.requestInterceptor({...r},{url:i})??void 0}catch(t){const e=t instanceof Error?t.message:String(t);this.diagnose?.("DA-MANIFEST-REQUEST-INTERCEPT-FAILED",e,{url:i}),this.log("Manifest request interceptor threw; using network fetch:",e),n=void 0}if(n&&"body"in n)return this.log("Manifest request interceptor returned a mock response (no network)"),"string"==typeof n.body?JSON.parse(n.body):n.body;const o=n??r,l=Date.now();let a;try{a=await t(o.url,o.headers?{headers:o.headers}:{},15e3,"Failed to fetch manifest: no response within 15000ms")}catch(t){const e=t instanceof Error&&t.message.includes("no response within");throw this.record(o.url,e?"timeout":"network",Date.now()-l),t}if(!a.ok)throw this.record(o.url,"http",Date.now()-l,a.status),new Error(`Failed to fetch manifest: ${a.status} ${a.statusText}`);this.record(o.url,"ok",Date.now()-l);const h=new Uint8Array(await a.arrayBuffer()),c=a.headers.get(s),u=e(c,h,this.trustedKeys);if(!u.valid){const t=`Manifest signature verification failed: ${u.reason}`;throw this.diagnose?.("DA-MANIFEST-SIGNATURE-INVALID",t,{url:o.url,reason:u.reason}),this.log("Rejecting manifest:",t),new Error(t)}return JSON.parse((new TextDecoder).decode(h))}async applyInterceptor(t){if(!this.interceptor)return t;const e=this.manifestUrl??"";try{return await this.interceptor(t,{url:e})}catch(s){const i=s instanceof Error?s.message:String(s);return this.diagnose?.("DA-MANIFEST-INTERCEPT-FAILED",i,{url:e}),this.log("Manifest interceptor threw; using un-modified manifest:",i),t}}parseManifest(t){return r(t,t=>{this.reportedEventIssues.has(t.key)||(this.reportedEventIssues.add(t.key),this.diagnose?.("DA-MANIFEST-EVENT-INVALID",t.message,t.context))})}startPolling(t,e){this.pollCallbacks={onUpdate:t,onError:e},this.applyPollingDecision()}stopPolling(){this.pollCallbacks=null,this.clearPollingTimer()}setActive(t){this.isActive!==t&&(this.isActive=t,this.log("Active state changed:",t),this.applyPollingDecision())}applyPollingDecision(){if(!this.pollCallbacks)return;const t=n(this.currentManifest?.polling,this.isActive);if("once"===t.mode)return this.pollingInterval&&this.log("Manifest has no polling object — fetch-once, stopping timer"),void this.clearPollingTimer();this.pollingInterval&&this.pollingIntervalSeconds===t.intervalSeconds||(this.clearPollingTimer(),this.log("Starting polling with interval:",t.intervalSeconds,"seconds"),this.pollingIntervalSeconds=t.intervalSeconds,this.pollingInterval=setInterval(async()=>{const t=this.pollCallbacks;if(t){try{const e=await this.fetchManifest();e.breaks.length>0&&this.log(`📡 Manifest: ${e.breaks.length} break(s)`),t.onUpdate(e)}catch(e){t.onError(e instanceof Error?e:new Error(String(e)))}this.applyPollingDecision()}},1e3*t.intervalSeconds))}clearPollingTimer(){this.pollingInterval&&(clearInterval(this.pollingInterval),this.pollingInterval=null,this.pollingIntervalSeconds=null,this.log("Polling stopped"))}getManifest(){return this.currentManifest}reset(){this.stopPolling(),this.currentManifest=null,this.manifestUrl=null,this.isActive=!1,this.reportedEventIssues.clear()}destroy(){this.stopPolling(),this.currentManifest=null,this.manifestUrl=null,this.reportedEventIssues.clear()}log(...t){this.debug&&o("[OptiViewAds:ManifestService]",...t)}}
1
+ import{fetchWithTimeout as t}from"./fetchWithTimeout";import{verifyManifestSignature as e,MANIFEST_SIGNATURE_HEADER as s}from"./manifestSignature";import{embeddedManifestKeys as i}from"./manifestTrustedKeys";import{parseBreakManifest as r}from"./parseBreakManifest";import{resolvePollingDecision as n}from"./resolvePollingDecision";import{debugLog as o}from"../diagnostics/debugLog";export const MANIFEST_FETCH_TIMEOUT_MS=15e3;export class ManifestService{get currentPollingIntervalSeconds(){return this.pollingIntervalSeconds}constructor(t=!1,e,s,r,n){this.manifestUrl=null,this.pollingInterval=null,this.pollingIntervalSeconds=null,this.pollCallbacks=null,this.currentManifest=null,this.isActive=!1,this.hostTally=new Map,this.reportedEventIssues=new Set,this.lastFailure=null,this.debug=t,this.interceptor=e,this.diagnose=s,this.requestInterceptor=r,this.trustedKeys=n??i()}takeLastFailure(){const t=this.lastFailure;return this.lastFailure=null,t}record(t,e,s,i){const r=function(t){try{return new URL(t).host}catch{return t}}(t),n=this.hostTally.get(r)??{ok:0,fail:0};if("ok"===e)return n.ok++,void this.hostTally.set(r,n);this.lastFailure={url:t,host:r,outcome:e,ms:s,hostOk:n.ok,hostFail:n.fail,...void 0===i?{}:{status:i}},n.fail++,this.hostTally.set(r,n)}setManifestUrl(t){this.manifestUrl=t,this.reportedEventIssues.clear()}async fetchManifest(){if(!this.manifestUrl)throw new Error("Manifest URL not set. Call setManifestUrl() before fetching.");this.log("Fetching manifest from:",this.manifestUrl);const t=await this.fetchManifestData(this.manifestUrl),e=this.parseManifest(t),s=await this.applyInterceptor(e);return this.currentManifest=s,this.log("Manifest loaded:",s.breaks.length,"breaks"),s}async fetchManifestData(i){const r={url:i};let n;if(this.requestInterceptor)try{n=await this.requestInterceptor({...r},{url:i})??void 0}catch(t){const e=t instanceof Error?t.message:String(t);this.diagnose?.("DA-MANIFEST-REQUEST-INTERCEPT-FAILED",e,{url:i}),this.log("Manifest request interceptor threw; using network fetch:",e),n=void 0}if(n&&"body"in n)return this.log("Manifest request interceptor returned a mock response (no network)"),"string"==typeof n.body?JSON.parse(n.body):n.body;const o=n??r,l=Date.now();let a;try{a=await t(o.url,o.headers?{headers:o.headers}:{},15e3,"Failed to fetch manifest: no response within 15000ms")}catch(t){const e=t instanceof Error&&t.message.includes("no response within");throw this.record(o.url,e?"timeout":"network",Date.now()-l),t}if(!a.ok)throw this.record(o.url,"http",Date.now()-l,a.status),new Error(`Failed to fetch manifest: ${a.status} ${a.statusText}`);this.record(o.url,"ok",Date.now()-l);const h=new Uint8Array(await a.arrayBuffer()),c=a.headers.get(s),u=e(c,h,this.trustedKeys);if(!u.valid){const t=`Manifest signature verification failed: ${u.reason}`;throw this.diagnose?.("DA-MANIFEST-SIGNATURE-INVALID",t,{url:o.url,reason:u.reason}),this.log("Rejecting manifest:",t),new Error(t)}return JSON.parse((new TextDecoder).decode(h))}async applyInterceptor(t){if(!this.interceptor)return t;const e=this.manifestUrl??"";try{return await this.interceptor(t,{url:e})}catch(s){const i=s instanceof Error?s.message:String(s);return this.diagnose?.("DA-MANIFEST-INTERCEPT-FAILED",i,{url:e}),this.log("Manifest interceptor threw; using un-modified manifest:",i),t}}parseManifest(t){return r(t,t=>{this.reportedEventIssues.has(t.key)||(this.reportedEventIssues.add(t.key),this.diagnose?.("DA-MANIFEST-EVENT-INVALID",t.message,t.context))},t=>{const e=`slate:${t.reason}:${String(t.context.assetId??"")}`;this.reportedEventIssues.has(e)||(this.reportedEventIssues.add(e),this.diagnose?.("DA-SLATE-INVALID",t.message,t.context))})}startPolling(t,e){this.pollCallbacks={onUpdate:t,onError:e},this.applyPollingDecision()}stopPolling(){this.pollCallbacks=null,this.clearPollingTimer()}setActive(t){this.isActive!==t&&(this.isActive=t,this.log("Active state changed:",t),this.applyPollingDecision())}applyPollingDecision(){if(!this.pollCallbacks)return;const t=n(this.currentManifest?.polling,this.isActive);if("once"===t.mode)return this.pollingInterval&&this.log("Manifest has no polling object — fetch-once, stopping timer"),void this.clearPollingTimer();this.pollingInterval&&this.pollingIntervalSeconds===t.intervalSeconds||(this.clearPollingTimer(),this.log("Starting polling with interval:",t.intervalSeconds,"seconds"),this.pollingIntervalSeconds=t.intervalSeconds,this.pollingInterval=setInterval(async()=>{const t=this.pollCallbacks;if(t){try{const e=await this.fetchManifest();e.breaks.length>0&&this.log(`📡 Manifest: ${e.breaks.length} break(s)`),t.onUpdate(e)}catch(e){t.onError(e instanceof Error?e:new Error(String(e)))}this.applyPollingDecision()}},1e3*t.intervalSeconds))}clearPollingTimer(){this.pollingInterval&&(clearInterval(this.pollingInterval),this.pollingInterval=null,this.pollingIntervalSeconds=null,this.log("Polling stopped"))}getManifest(){return this.currentManifest}reset(){this.stopPolling(),this.currentManifest=null,this.manifestUrl=null,this.isActive=!1,this.reportedEventIssues.clear()}destroy(){this.stopPolling(),this.currentManifest=null,this.manifestUrl=null,this.reportedEventIssues.clear()}log(...t){this.debug&&o("[OptiViewAds:ManifestService]",...t)}}
@@ -18,12 +18,12 @@ export type PauseAdHideCallback = (breakId: string) => void;
18
18
  /** Fired once per shown pause ad when the viewer may resume content (see {@link PauseAdController.canResume}). */
19
19
  export type PauseAdResumeAllowedCallback = (breakId: string) => void;
20
20
  /** Structured diagnostic sink. */
21
- export type PauseAdDiagnoseCallback = (code: 'DA-PAUSE-AD-SHOWN' | 'DA-PAUSE-AD-DISMISSED' | 'DA-PAUSE-AD-NO-ASSET' | 'DA-PAUSE-AD-RESUME-ALLOWED' | 'DA-PAUSE-AD-RESUME-BLOCKED', message: string, context?: Record<string, unknown>) => void;
21
+ export type PauseAdDiagnoseCallback = (code: 'DA-PAUSE-AD-SHOWN' | 'DA-PAUSE-AD-DISMISSED' | 'DA-PAUSE-AD-NO-ASSET' | 'DA-PAUSE-AD-ROTATION-EXHAUSTED' | 'DA-PAUSE-AD-RESUME-ALLOWED' | 'DA-PAUSE-AD-RESUME-BLOCKED', message: string, context?: Record<string, unknown>) => void;
22
22
  /**
23
23
  * Event-driven pause-ad state machine (spec `EventTrigger` with `event: 'pause'`).
24
24
  *
25
- * Every pause consumes the next pause break in manifest order, wrapping to the first once the
26
- * list is exhausted. The break's `start.delay` must elapse while still paused before it shows;
25
+ * Every pause consumes the next pause break in manifest order until the list is exhausted.
26
+ * The break's `start.delay` must elapse while still paused before it shows;
27
27
  * `duration` caps the display. While shown, content may resume only once `controls.skipOffset`
28
28
  * elapsed — or, without a skipOffset, once a video creative ended or an image reached its
29
29
  * duration cap (an image with neither can be resumed at once, so the viewer is never locked).
@@ -56,6 +56,8 @@ export declare class PauseAdController {
56
56
  private resumeAnnounced;
57
57
  /** Whether the shown video creative ended (or cannot play). */
58
58
  private creativeEnded;
59
+ private resolvedKind;
60
+ private creativeFailed;
59
61
  constructor(options?: {
60
62
  clock?: Clock;
61
63
  debug?: boolean;
@@ -67,7 +69,7 @@ export declare class PauseAdController {
67
69
  onResumeAllowed?: PauseAdResumeAllowedCallback;
68
70
  diagnose?: PauseAdDiagnoseCallback;
69
71
  }): void;
70
- /** Replace the pause-break list. A shown ad stays up; the rotation index is kept (wrapped). */
72
+ /** Replace the pause-break list without rewinding the rotation position. A shown ad stays up. */
71
73
  updateManifest(manifest: BreakManifest): void;
72
74
  /** Notify the controller that content was paused (from the `pause` event). */
73
75
  onPause(): void;
@@ -81,6 +83,8 @@ export declare class PauseAdController {
81
83
  tick(): void;
82
84
  /** Whether the pause ad is currently shown. */
83
85
  isShowing(): boolean;
86
+ /** Apply the rendered kind before announcing resume; null means resolution or playback failed. */
87
+ onCreativeResolved(breakId: string, kind: MediaType | null): void;
84
88
  /** Notify the controller that the shown video creative ended or cannot play (from the renderer). */
85
89
  onCreativeEnded(breakId: string): void;
86
90
  /**
@@ -91,7 +95,7 @@ export declare class PauseAdController {
91
95
  canResume(): boolean;
92
96
  private evaluate;
93
97
  /**
94
- * Next usable pause break in manifest order, starting at the rotation index and wrapping once.
98
+ * Next usable pause break in manifest order, starting at the rotation index without wrapping.
95
99
  * Unusable breaks are skipped (diagnosed) so they never swallow a pause.
96
100
  */
97
101
  private selectNext;
@@ -1 +1 @@
1
- import{AssetType as e,isEventTrigger as s,isPauseBreak as t,MediaType as n}from"../types/BreakManifest";import{systemClock as i}from"./BreakScheduler";import{debugLog as h}from"../diagnostics/debugLog";export class PauseAdController{constructor(e){this.onShow=null,this.onHide=null,this.onResumeAllowed=null,this.diagnose=null,this.pauseBreaks=[],this.nextIndex=0,this.current=null,this.paused=!1,this.pausedAtMs=null,this.pending=null,this.shownAtMs=null,this.resumeAnnounced=!1,this.creativeEnded=!1,this.clock=e?.clock??i,this.debug=e?.debug??!1}setCallbacks(e){this.onShow=e.onShow??null,this.onHide=e.onHide??null,this.onResumeAllowed=e.onResumeAllowed??null,this.diagnose=e.diagnose??null}updateManifest(e){this.pauseBreaks=e.breaks.filter(t),0===this.pauseBreaks.length?this.nextIndex=0:this.nextIndex%=this.pauseBreaks.length}onPause(){this.paused||(this.paused=!0,this.pausedAtMs=this.clock.now(),this.pending=this.selectNext(),this.log("content paused"),this.evaluate())}onResume(){if(!this.paused&&null===this.shownAtMs)return!0;if(null!==this.shownAtMs&&!this.canResume()){const e=this.current;return this.diagnose?.("DA-PAUSE-AD-RESUME-BLOCKED",`Pause ad "${e.break.id}" must still be watched; content stays paused.`,{breakId:e.break.id,elapsedSec:(this.clock.now()-this.shownAtMs)/1e3,skipOffset:e.break.controls?.skipOffset??null}),!1}return this.paused=!1,this.pausedAtMs=null,this.pending=null,this.hide("resume"),!0}tick(){this.evaluate()}isShowing(){return null!==this.shownAtMs}onCreativeEnded(e){null!==this.shownAtMs&&this.current?.break.id===e&&(this.creativeEnded=!0,this.announceResumeIfAllowed())}canResume(){if(null===this.shownAtMs||!this.current)return!0;const e=this.clock.now()-this.shownAtMs,s=this.current.break.controls?.skipOffset;if(void 0!==s)return e>=1e3*s;if(this.current.mediaType===n.VIDEO)return this.creativeEnded;const t=this.current.break.duration??0;return t<=0||e>=1e3*t}evaluate(){if(null!==this.shownAtMs&&this.current){const e=this.current.break.duration??0;return e>0&&this.clock.now()-this.shownAtMs>=1e3*e?void this.hide("duration"):void this.announceResumeIfAllowed()}if(!this.paused||null===this.pausedAtMs||!this.pending)return;const{index:e,resolution:t}=this.pending,{start:n}=t.break,i=s(n)&&n.delay||0;this.clock.now()-this.pausedAtMs<1e3*i||(this.pending=null,this.nextIndex=(e+1)%this.pauseBreaks.length,this.show(t))}selectNext(){const e=this.pauseBreaks.length;for(let s=0;s<e;s++){const t=(this.nextIndex+s)%e,n=this.resolve(this.pauseBreaks[t]);if(n)return{index:t,resolution:n}}return null}resolve(s){const t=Array.isArray(s.variant)?s.variant[0]:s.variant,i=(t?.assets??[])[0];if(!i||i.mediaType!==n.IMAGE&&i.mediaType!==n.VIDEO)return this.diagnose?.("DA-PAUSE-AD-NO-ASSET",`Pause break "${s.id}" has no image or video asset; skipping.`,{breakId:s.id}),null;return{break:s,asset:i,source:i.type===e.VAST?"vast":"static",mediaType:i.mediaType}}show(e){this.current=e,this.shownAtMs=this.clock.now(),this.resumeAnnounced=!1,this.creativeEnded=!1,this.log(`showing pause ad "${e.break.id}" (${e.source}, ${e.mediaType})`),this.diagnose?.("DA-PAUSE-AD-SHOWN",`Pause ad "${e.break.id}" shown.`,{breakId:e.break.id,source:e.source,mediaType:e.mediaType,skipOffset:e.break.controls?.skipOffset??null}),this.onShow?.(e),this.announceResumeIfAllowed()}announceResumeIfAllowed(){if(this.resumeAnnounced||!this.current||!this.canResume())return;this.resumeAnnounced=!0;const e=this.current.break.id;this.log(`pause ad "${e}" may be resumed`),this.diagnose?.("DA-PAUSE-AD-RESUME-ALLOWED",`Pause ad "${e}" may be resumed.`,{breakId:e}),this.onResumeAllowed?.(e)}hide(e){if(null===this.shownAtMs)return;const s=this.current?.break.id??"";this.shownAtMs=null,this.current=null,this.resumeAnnounced=!1,this.creativeEnded=!1,this.log(`hiding pause ad "${s}" (${e})`),this.diagnose?.("DA-PAUSE-AD-DISMISSED",`Pause ad "${s}" dismissed (${e}).`,{breakId:s,reason:e}),this.onHide?.(s)}reset(){this.clearState()}destroy(){this.clearState(),this.onShow=null,this.onHide=null,this.onResumeAllowed=null,this.diagnose=null}clearState(){this.pauseBreaks=[],this.nextIndex=0,this.current=null,this.paused=!1,this.pausedAtMs=null,this.pending=null,this.shownAtMs=null,this.resumeAnnounced=!1}log(...e){this.debug&&h("[PauseAdController]",...e)}}
1
+ import{AssetType as e,isEventTrigger as s,isPauseBreak as t,MediaType as i}from"../types/BreakManifest";import{systemClock as n}from"./BreakScheduler";import{debugLog as r}from"../diagnostics/debugLog";export class PauseAdController{constructor(e){this.onShow=null,this.onHide=null,this.onResumeAllowed=null,this.diagnose=null,this.pauseBreaks=[],this.nextIndex=0,this.current=null,this.paused=!1,this.pausedAtMs=null,this.pending=null,this.shownAtMs=null,this.resumeAnnounced=!1,this.creativeEnded=!1,this.resolvedKind=null,this.creativeFailed=!1,this.clock=e?.clock??n,this.debug=e?.debug??!1}setCallbacks(e){this.onShow=e.onShow??null,this.onHide=e.onHide??null,this.onResumeAllowed=e.onResumeAllowed??null,this.diagnose=e.diagnose??null}updateManifest(e){this.pauseBreaks=e.breaks.filter(t)}onPause(){this.paused||(this.paused=!0,this.pausedAtMs=this.clock.now(),this.pending=this.selectNext(),this.log("content paused"),this.evaluate())}onResume(){if(!this.paused&&null===this.shownAtMs)return!0;if(null!==this.shownAtMs&&!this.canResume()){const e=this.current;return this.diagnose?.("DA-PAUSE-AD-RESUME-BLOCKED",`Pause ad "${e.break.id}" must still be watched; content stays paused.`,{breakId:e.break.id,elapsedSec:(this.clock.now()-this.shownAtMs)/1e3,skipOffset:e.break.controls?.skipOffset??null}),!1}return this.paused=!1,this.pausedAtMs=null,this.pending=null,this.hide("resume"),!0}tick(){this.evaluate()}isShowing(){return null!==this.shownAtMs}onCreativeResolved(e,s){null!==this.shownAtMs&&this.current?.break.id===e&&(this.resolvedKind=s,this.creativeFailed=null===s,this.announceResumeIfAllowed())}onCreativeEnded(e){null!==this.shownAtMs&&this.current?.break.id===e&&(this.creativeEnded=!0,this.announceResumeIfAllowed())}canResume(){if(null===this.shownAtMs||!this.current)return!0;if(this.creativeFailed)return!0;if("vast"===this.current.source&&null===this.resolvedKind)return this.creativeEnded;const e=this.clock.now()-this.shownAtMs,s=this.current.break.controls?.skipOffset;if(void 0!==s)return e>=1e3*s;if(this.resolvedKind===i.VIDEO)return this.creativeEnded;const t=this.current.break.duration??0;return t<=0||e>=1e3*t}evaluate(){if(null!==this.shownAtMs&&this.current){const e=this.current.break.duration??0;return e>0&&this.clock.now()-this.shownAtMs>=1e3*e?void this.hide("duration"):void this.announceResumeIfAllowed()}if(!this.paused||null===this.pausedAtMs||!this.pending)return;const{index:e,resolution:t}=this.pending,{start:i}=t.break,n=s(i)&&i.delay||0;this.clock.now()-this.pausedAtMs<1e3*n||(this.pending=null,this.nextIndex=e+1,this.show(t))}selectNext(){const e=this.pauseBreaks.length;for(let s=this.nextIndex;s<e;s++){const e=this.resolve(this.pauseBreaks[s]);if(e)return{index:s,resolution:e}}return this.nextIndex=Math.max(this.nextIndex,e),e>0&&this.diagnose?.("DA-PAUSE-AD-ROTATION-EXHAUSTED","Pause ad rotation exhausted; nothing shown."),null}resolve(s){const t=Array.isArray(s.variant)?s.variant[0]:s.variant,n=(t?.assets??[])[0];if(!n||n.mediaType!==i.IMAGE&&n.mediaType!==i.VIDEO)return this.diagnose?.("DA-PAUSE-AD-NO-ASSET",`Pause break "${s.id}" has no image or video asset; skipping.`,{breakId:s.id}),null;return{break:s,asset:n,source:n.type===e.VAST?"vast":"static",mediaType:n.mediaType}}show(e){this.resolvedKind="static"===e.source?e.mediaType:null,this.creativeFailed=!1,this.current=e,this.shownAtMs=this.clock.now(),this.resumeAnnounced=!1,this.creativeEnded=!1,this.log(`showing pause ad "${e.break.id}" (${e.source}, ${e.mediaType})`),this.diagnose?.("DA-PAUSE-AD-SHOWN",`Pause ad "${e.break.id}" shown.`,{breakId:e.break.id,source:e.source,mediaType:e.mediaType,skipOffset:e.break.controls?.skipOffset??null}),this.onShow?.(e),this.announceResumeIfAllowed()}announceResumeIfAllowed(){if(this.resumeAnnounced||!this.current||!this.canResume())return;this.resumeAnnounced=!0;const e=this.current.break.id;this.log(`pause ad "${e}" may be resumed`),this.diagnose?.("DA-PAUSE-AD-RESUME-ALLOWED",`Pause ad "${e}" may be resumed.`,{breakId:e}),this.onResumeAllowed?.(e)}hide(e){if(null===this.shownAtMs)return;const s=this.current?.break.id??"";this.shownAtMs=null,this.current=null,this.resumeAnnounced=!1,this.creativeEnded=!1,this.log(`hiding pause ad "${s}" (${e})`),this.diagnose?.("DA-PAUSE-AD-DISMISSED",`Pause ad "${s}" dismissed (${e}).`,{breakId:s,reason:e}),this.onHide?.(s)}reset(){this.clearState()}destroy(){this.clearState(),this.onShow=null,this.onHide=null,this.onResumeAllowed=null,this.diagnose=null}clearState(){this.pauseBreaks=[],this.nextIndex=0,this.current=null,this.paused=!1,this.pausedAtMs=null,this.pending=null,this.shownAtMs=null,this.resumeAnnounced=!1}log(...e){this.debug&&r("[PauseAdController]",...e)}}
@@ -0,0 +1,4 @@
1
+ import { BreakFormat, type Break } from '../types/BreakManifest';
2
+ import type { BreakStartContext } from './BreakSchedulerTypes';
3
+ export declare function lastPlaybackCrossing(breaks: Break[]): Break | undefined;
4
+ export declare function playbackCrossingContext(brk: Break, position: number, format?: BreakFormat, seekLanding?: number | null): BreakStartContext;
@@ -0,0 +1 @@
1
+ import{BreakFormat as t}from"../types/BreakManifest";export function lastPlaybackCrossing(t){const r=t.sort((t,r)=>Number(t.start)-Number(r.start)||(t.id<r.id?-1:t.id>r.id?1:0));return r[r.length-1]}export function playbackCrossingContext(r,n,e,s=null){const a=n-Number(r.start),i=(void 0===e||e===t.SINGLE||e===t.LSHAPE_AD)&&(a>5||null!==s);return{tuneIn:!1,elapsedSec:a,remainingSec:r.duration??0,...i?{snapback:{resumePosition:s??Number(r.start)}}:{}}}
@@ -1 +1 @@
1
- import{BreakState as e}from"./BreakScheduler";import{Timebase as s}from"../types/BreakManifest";export class RewindRearm{constructor(e){this.deps=e,this._adStartDelayMs=0,this.adStartDelayElapsedLogged=!1,this.adStartDelayIgnored=!1,this.channelPlayedBaselineMs=0,this._playbackStarted=!1,this._playedMs=0,this.lastPlayedPositionMs=null,this.lastPlayedClockMs=null,this.contentReloading=!1,this.resumeSeekBaselineMs=null,this.resumeSeekBreakEndMs=null,this.resumeSeekInFlight=!1,this.pendingRewindFromMs=null,this.lastCurrentTime=null,this.REWIND_JITTER_TOLERANCE_MS=2e3}isAdStartDelayActive(){return this._adStartDelayMs>0&&this._playedMs<this._adStartDelayMs}get playbackStarted(){return this._playbackStarted}get playedMs(){return this._playedMs}markPlaybackStart(e){this._playbackStarted=!0,this.lastPlayedPositionMs=e,this.lastPlayedClockMs=this.deps.clock.now()}setAdStartDelayMs(e){this.adStartDelayIgnored||(this._adStartDelayMs=e)}ignoreAdStartDelay(){this._playbackStarted&&(this._adStartDelayMs=0,this.adStartDelayIgnored=!0)}beginChannel(){this.channelPlayedBaselineMs=this._playedMs,this.ignoreAdStartDelay()}get channelPlayedMs(){return this._playedMs-this.channelPlayedBaselineMs}get adStartDelayMs(){return this._adStartDelayMs}setContentReloading(e){this.contentReloading=e,e||(this.lastCurrentTime=null),this.pendingRewindFromMs=null}notifyResumeSeek(e,s){Number.isFinite(e)&&Number.isFinite(s)&&(this.resumeSeekBaselineMs=1e3*e,this.resumeSeekBreakEndMs=1e3*s,this.resumeSeekInFlight=!0,this.lastCurrentTime=this.resumeSeekBaselineMs,this.pendingRewindFromMs=null)}notifyViewerSeek(){this.resumeSeekBaselineMs=null,this.resumeSeekBreakEndMs=null,this.resumeSeekInFlight=!1}trackPlayedTime(e){if(!this._playbackStarted)return;const s=this.deps.clock.now();if(null===e)return this.lastPlayedPositionMs=null,void(this.lastPlayedClockMs=s);if(null!==this.lastPlayedPositionMs&&null!==this.lastPlayedClockMs){const t=e-this.lastPlayedPositionMs,i=s-this.lastPlayedClockMs;this._playedMs+=Math.min(Math.max(0,t),Math.max(0,i)),this._adStartDelayMs>0&&this._playedMs>=this._adStartDelayMs&&!this.adStartDelayElapsedLogged&&(this.adStartDelayElapsedLogged=!0,this.deps.log(`⏰ Ad start delay elapsed (${this._adStartDelayMs/1e3}s played)`))}this.lastPlayedPositionMs=e,this.lastPlayedClockMs=s}rearmOnRewind(e){const s=this.lastCurrentTime;if(this.lastCurrentTime=e,null!==this.resumeSeekBreakEndMs&&e>this.resumeSeekBreakEndMs&&(this.resumeSeekBaselineMs=null,this.resumeSeekBreakEndMs=null,this.resumeSeekInFlight=!1),this.contentReloading)this.pendingRewindFromMs=null;else{if(null!==this.pendingRewindFromMs){const s=this.pendingRewindFromMs;if(this.pendingRewindFromMs=null,this.isRewindBeyondJitter(s,e))return void this.rearmRetiredBreaks(e)}null===s||e>=s?this.resumeSeekInFlight&&null!==s&&e>s&&(this.resumeSeekInFlight=!1,this.resumeSeekBaselineMs=null,this.resumeSeekBreakEndMs=null):this.isRewindBeyondJitter(s,e)&&(this.resumeSeekInFlight&&null!==this.resumeSeekBreakEndMs&&e>=0&&e<=this.resumeSeekBreakEndMs||(this.pendingRewindFromMs=s))}}reset(){this.lastCurrentTime=null,this._adStartDelayMs=0,this.adStartDelayElapsedLogged=!1,this._playbackStarted=!1,this._playedMs=0,this.adStartDelayIgnored=!1,this.channelPlayedBaselineMs=0,this.lastPlayedPositionMs=null,this.lastPlayedClockMs=null,this.resumeSeekBaselineMs=null,this.resumeSeekBreakEndMs=null,this.resumeSeekInFlight=!1,this.pendingRewindFromMs=null}isRewindBeyondJitter(e,t){return!(t>=e)&&!(this.deps.timing.timebase===s.WALLCLOCK&&e-t<=this.REWIND_JITTER_TOLERANCE_MS)}rearmRetiredBreaks(s){if(this.resumeSeekBaselineMs=null,this.resumeSeekBreakEndMs=null,this.resumeSeekInFlight=!1,0!==this.deps.retiredBreaks.size)for(const[t,i]of[...this.deps.retiredBreaks]){const a=this.deps.breaks.get(t);if(a&&(a.state===e.PENDING||a.state===e.ACTIVE||a.state===e.CHAINED))continue;const l=this.deps.getBreakStartTime(i);if(null===l)continue;s<l+1e3*(i.duration??0)&&(this.deps.retiredBreaks.delete(t),this.deps.completedBreakIds.delete(t),this.deps.preloadLedger.deletePreloaded(t),this.deps.breaks.set(t,{break:i,state:e.PENDING}),this.deps.log(`↩️ Re-armed break "${t}" after backward seek`))}}}
1
+ import{BreakState as e}from"./BreakScheduler";import{Timebase as s}from"../types/BreakManifest";export class RewindRearm{constructor(e){this.deps=e,this._adStartDelayMs=0,this.adStartDelayElapsedLogged=!1,this.adStartDelayIgnored=!1,this.channelPlayedBaselineMs=0,this._playbackStarted=!1,this._playedMs=0,this.lastPlayedPositionMs=null,this.lastPlayedClockMs=null,this.contentReloading=!1,this.resumeSeekBaselineMs=null,this.resumeSeekBreakEndMs=null,this.resumeSeekInFlight=!1,this.pendingRewindFromMs=null,this.lastCurrentTime=null,this.REWIND_JITTER_TOLERANCE_MS=2e3}isAdStartDelayActive(){return this._adStartDelayMs>0&&this._playedMs<this._adStartDelayMs}get playbackStarted(){return this._playbackStarted}get playedMs(){return this._playedMs}markPlaybackStart(e){this._playbackStarted=!0,this.lastPlayedPositionMs=e,this.lastPlayedClockMs=this.deps.clock.now()}setAdStartDelayMs(e){this.adStartDelayIgnored||(this._adStartDelayMs=e)}ignoreAdStartDelay(){this._playbackStarted&&(this._adStartDelayMs=0,this.adStartDelayIgnored=!0)}beginChannel(){this.channelPlayedBaselineMs=this._playedMs,this.ignoreAdStartDelay()}get channelPlayedMs(){return this._playedMs-this.channelPlayedBaselineMs}get adStartDelayMs(){return this._adStartDelayMs}setContentReloading(e){this.contentReloading=e,e||(this.lastCurrentTime=null),this.pendingRewindFromMs=null}notifyResumeSeek(e,s){Number.isFinite(e)&&Number.isFinite(s)&&(this.resumeSeekBaselineMs=1e3*e,this.resumeSeekBreakEndMs=1e3*s,this.resumeSeekInFlight=!0,this.lastCurrentTime=this.resumeSeekBaselineMs,this.pendingRewindFromMs=null)}notifyViewerSeek(){this.resumeSeekBaselineMs=null,this.resumeSeekBreakEndMs=null,this.resumeSeekInFlight=!1}trackPlayedTime(e){if(!this._playbackStarted)return;const s=this.deps.clock.now();if(null===e)return this.lastPlayedPositionMs=null,void(this.lastPlayedClockMs=s);if(null!==this.lastPlayedPositionMs&&null!==this.lastPlayedClockMs){const t=e-this.lastPlayedPositionMs,i=s-this.lastPlayedClockMs;this._playedMs+=Math.min(Math.max(0,t),Math.max(0,i)),this._adStartDelayMs>0&&this._playedMs>=this._adStartDelayMs&&!this.adStartDelayElapsedLogged&&(this.adStartDelayElapsedLogged=!0,this.deps.log(`⏰ Ad start delay elapsed (${this._adStartDelayMs/1e3}s played)`))}this.lastPlayedPositionMs=e,this.lastPlayedClockMs=s}rearmOnRewind(e){const s=this.lastCurrentTime;if(this.lastCurrentTime=e,null!==this.resumeSeekBreakEndMs&&e>this.resumeSeekBreakEndMs&&(this.resumeSeekBaselineMs=null,this.resumeSeekBreakEndMs=null,this.resumeSeekInFlight=!1),this.contentReloading)this.pendingRewindFromMs=null;else{if(null!==this.pendingRewindFromMs){const s=this.pendingRewindFromMs;if(this.pendingRewindFromMs=null,this.isRewindBeyondJitter(s,e))return void this.rearmRetiredBreaks(e)}null===s||e>=s?this.resumeSeekInFlight&&null!==s&&e>s&&(this.resumeSeekInFlight=!1,this.resumeSeekBaselineMs=null,this.resumeSeekBreakEndMs=null):this.isRewindBeyondJitter(s,e)&&(this.resumeSeekInFlight&&null!==this.resumeSeekBreakEndMs&&e>=0&&e<=this.resumeSeekBreakEndMs||(this.pendingRewindFromMs=s))}}reset(){this.lastCurrentTime=null,this._adStartDelayMs=0,this.adStartDelayElapsedLogged=!1,this._playbackStarted=!1,this._playedMs=0,this.adStartDelayIgnored=!1,this.channelPlayedBaselineMs=0,this.lastPlayedPositionMs=null,this.lastPlayedClockMs=null,this.resumeSeekBaselineMs=null,this.resumeSeekBreakEndMs=null,this.resumeSeekInFlight=!1,this.pendingRewindFromMs=null}isRewindBeyondJitter(e,t){return!(t>=e)&&!(this.deps.timing.timebase===s.WALLCLOCK&&e-t<=this.REWIND_JITTER_TOLERANCE_MS)}rearmRetiredBreaks(t){if(this.resumeSeekBaselineMs=null,this.resumeSeekBreakEndMs=null,this.resumeSeekInFlight=!1,0!==this.deps.retiredBreaks.size)for(const[i,a]of[...this.deps.retiredBreaks]){const l=this.deps.breaks.get(i);if(l&&(l.state===e.PENDING||l.state===e.ACTIVE||l.state===e.CHAINED))continue;const n=this.deps.getBreakStartTime(a);if(null===n)continue;t<(this.deps.timing.timebase===s.MEDIATIME?n:n+1e3*(a.duration??0))&&(this.deps.retiredBreaks.delete(i),this.deps.completedBreakIds.delete(i),this.deps.preloadLedger.deletePreloaded(i),this.deps.breaks.set(i,{break:a,state:e.PENDING}),this.deps.log(`↩️ Re-armed break "${i}" after backward seek`))}}}
@@ -22,6 +22,7 @@ export declare class SchedulerSnapback {
22
22
  position: number;
23
23
  };
24
24
  preload: (brk: Break, label: string) => void;
25
+ skip: (tracked: TrackedBreak) => void;
25
26
  });
26
27
  private eligibility;
27
28
  private breaks;
@@ -1 +1 @@
1
- import{Timebase as s}from"../types/BreakManifest";import{BreakState as e}from"./BreakSchedulerTypes";import{SnapbackGuard as t}from"./SnapbackGuard";export class SchedulerSnapback{constructor(s){this.deps=s,this.enabled=!1,this.onCorrect=null,this.guard=new t(s.diagnose)}eligibility(){const e=this.deps.state();return{...e,enabled:this.enabled&&e.timebase===s.MEDIATIME}}breaks(){const s=new Map(this.deps.retiredBreaks);for(const[e,t]of this.deps.breaks)s.set(e,t.break);return[...s.values()]}arm(s){const t=this.breaks().find(e=>e.id===s.breakId);t&&(this.deps.completedBreakIds.delete(t.id),this.deps.breaks.set(t.id,{break:t,state:e.PENDING}),this.deps.preload(t,"VOD snapback"))}planSeek(s){const e=this.guard.plan(s,this.breaks(),this.eligibility(),this.deps.clock.now(),this.canPlay);return e&&this.arm(e),e}notifySeeked(){const s=this.guard.seeked(this.deps.state().position,this.breaks(),this.eligibility(),this.deps.clock.now(),this.canPlay);s&&(this.arm(s),this.onCorrect?.(s))}sample(){return this.guard.sample(this.deps.state().position,this.breaks(),this.eligibility(),this.deps.clock.now(),s=>{this.arm(s),this.onCorrect?.(s)},this.canPlay)}}
1
+ import{Timebase as e}from"../types/BreakManifest";import{BreakState as s}from"./BreakSchedulerTypes";import{SnapbackGuard as t}from"./SnapbackGuard";import{lastPlaybackCrossing as i}from"./PlaybackCrossing";import{selectVariantDetailed as r}from"./selectVariant";export class SchedulerSnapback{constructor(e){this.deps=e,this.enabled=!1,this.onCorrect=null,this.guard=new t(e.diagnose)}eligibility(){const s=this.deps.state();return{...s,enabled:this.enabled&&s.timebase===e.MEDIATIME}}breaks(){const e=new Map(this.deps.retiredBreaks);for(const[s,t]of this.deps.breaks)e.set(s,t.break);return[...e.values()]}arm(e){const t=this.breaks().find(s=>s.id===e.breakId);t&&(this.deps.completedBreakIds.delete(t.id),this.deps.breaks.set(t.id,{break:t,state:s.PENDING}),this.deps.preload(t,"VOD snapback"))}planSeek(e){this.guard.noteSeek(e);const s=this.guard.plan(e,this.breaks(),this.eligibility(),this.deps.clock.now(),this.canPlay);return s&&this.arm(s),s}notifySeeked(){const e=this.guard.seeked(this.deps.state().position,this.breaks(),this.eligibility(),this.deps.clock.now(),this.canPlay);e&&(this.arm(e),this.onCorrect?.(e))}sample(){const t=this.guard.sample(this.deps.state().position,this.breaks(),this.eligibility(),this.deps.clock.now(),e=>{this.arm(e),this.onCorrect?.(e)},this.canPlay),a=this.deps.state();if(t.waiting||t.ready||a.timebase!==e.MEDIATIME||!a.started||a.held||a.active)return t;const o=[...this.deps.breaks.values()].filter(e=>e.state===s.PENDING&&this.guard.crossedStart(Number(e.break.start),e.break.id)),d=i(o.map(e=>e.break));if(!d)return t;for(const e of o)e.break.id!==d.id&&(this.deps.skip(e),this.deps.retiredBreaks.set(e.break.id,e.break));return this.guard.blocksTuneIn(d,a.timebase)||!(this.canPlay?.(d)??null!==r(d).selected)?t:this.guard.beginPlayback(d,a.position,this.deps.clock.now(),e=>{this.arm(e),this.onCorrect?.(e)})??t}}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The slate's clock: it ends at `deadlineMs`, the break's scheduled end on the wall clock, however
3
+ * long the slate took to load, stalled or looped. Only time between {@link onPresented} and
4
+ * {@link onInterrupted} counts as presented (on-screen) time, which is reported but never moves
5
+ * the deadline. Pure: every method takes the time, the renderer owns the timer.
6
+ */
7
+ export declare class SlateClock {
8
+ private readonly deadlineMs;
9
+ private presentedAccumMs;
10
+ private presentedSince;
11
+ constructor(deadlineMs: number);
12
+ /** The slate is on screen and playing (video `playing`, image `load`); idempotent. */
13
+ onPresented(nowMs: number): void;
14
+ /** The slate stopped presenting (stall, loop seek, background); idempotent. */
15
+ onInterrupted(nowMs: number): void;
16
+ get presenting(): boolean;
17
+ /** Milliseconds the slate has been on screen so far. */
18
+ presentedMs(nowMs: number): number;
19
+ /** Milliseconds of wall time left until the deadline. */
20
+ remainingMs(nowMs: number): number;
21
+ complete(nowMs: number): boolean;
22
+ }
@@ -0,0 +1 @@
1
+ export class SlateClock{constructor(e){this.deadlineMs=e,this.presentedAccumMs=0,this.presentedSince=null}onPresented(e){null===this.presentedSince&&(this.presentedSince=e)}onInterrupted(e){null!==this.presentedSince&&(this.presentedAccumMs+=Math.max(0,e-this.presentedSince),this.presentedSince=null)}get presenting(){return null!==this.presentedSince}presentedMs(e){const t=null===this.presentedSince?0:Math.max(0,e-this.presentedSince);return this.presentedAccumMs+t}remainingMs(e){return Math.max(0,this.deadlineMs-e)}complete(e){return e>=this.deadlineMs}}
@@ -1,5 +1,7 @@
1
- import { type Break } from '../types/BreakManifest';
1
+ import { BreakFormat, Timebase, type Break } from '../types/BreakManifest';
2
2
  import type { SchedulerDiagnose } from './BreakSchedulerTypes';
3
+ import type { BreakStartContext } from './BreakSchedulerTypes';
4
+ import { BookmarkStart } from './BookmarkStart';
3
5
  export interface SnapbackRequest {
4
6
  breakId: string;
5
7
  target: number;
@@ -14,18 +16,50 @@ export interface SnapbackEligibility {
14
16
  /** Portable seek policy. All positions are content seconds; deadlines use the injected clock. */
15
17
  export declare class SnapbackGuard {
16
18
  private readonly diagnose;
19
+ selectFormat: ((brk: Break) => BreakFormat | undefined) | undefined;
20
+ readonly bookmark: BookmarkStart;
17
21
  private played;
22
+ private skipped;
18
23
  private lastPosition;
24
+ private observedPosition;
25
+ private playbackFrom;
26
+ private sampledStarts;
27
+ private previousStarts;
28
+ private observedAt;
29
+ private pendingSeek;
30
+ private viewerSeeking;
31
+ private plannedTarget;
32
+ private plannedDeadline;
33
+ private playbackContext;
34
+ private seekArrival;
19
35
  private internalTarget;
20
36
  private internalDeadline;
21
37
  private armed;
22
38
  constructor(diagnose: SchedulerDiagnose | null);
23
39
  markPlayed(id: string): void;
24
- expectSeek(target: number, now: number): void;
40
+ hasPlayed(id: string): boolean;
41
+ blocksTuneIn(brk: Break, timebase: Timebase): boolean;
42
+ isSkipped(id: string): boolean;
43
+ noteSeek(target: number): void;
44
+ noteViewerSeek(): void;
45
+ blocksSeekStart(brk: Break, timebase: Timebase): boolean;
46
+ private sampleArrival;
47
+ crossedStart(start: number, id: string): boolean;
48
+ captureStart(position: number, timebase: Timebase): void;
49
+ private resumeSeekPending;
50
+ private resumeStalePosition;
51
+ expectSeek(target: number, now: number, resume?: boolean): void;
25
52
  reset(): void;
53
+ cancelResumeSeek(): void;
26
54
  resetPosition(): void;
27
55
  private isInternal;
28
56
  plan(target: number, breaks: Break[], eligibility: SnapbackEligibility, now: number, canPlay?: (brk: Break) => boolean): SnapbackRequest | null;
57
+ startContext(request: SnapbackRequest, duration: number): BreakStartContext;
58
+ beginPlayback(brk: Break, position: number, now: number, correct: (request: SnapbackRequest) => void): {
59
+ waiting: boolean;
60
+ internal: boolean;
61
+ ready?: SnapbackRequest;
62
+ } | null;
29
63
  seeked(position: number, breaks: Break[], eligibility: SnapbackEligibility, now: number, canPlay?: (brk: Break) => boolean): SnapbackRequest | null;
30
64
  sample(position: number, breaks: Break[], eligibility: SnapbackEligibility, now: number, correct: (request: SnapbackRequest) => void, canPlay?: (brk: Break) => boolean): {
31
65
  waiting: boolean;
@@ -1 +1 @@
1
- import{getBreakEvent as t}from"../types/BreakManifest";import{selectVariantDetailed as e}from"./selectVariant";export class SnapbackGuard{constructor(t){this.diagnose=t,this.played=new Set,this.lastPosition=null,this.internalTarget=null,this.internalDeadline=0,this.armed=null}markPlayed(t){this.played.add(t)}expectSeek(t,e){this.internalTarget=t,this.internalDeadline=e+2e3,this.lastPosition=t}reset(){this.played.clear(),this.resetPosition()}resetPosition(){this.lastPosition=null,this.internalTarget=null,this.armed=null}isInternal(t,e){return null!==this.internalTarget&&(e<=this.internalDeadline&&Math.abs(t-this.internalTarget)<=.5||(this.internalTarget=null,!1))}plan(i,a,n,s,r=t=>null!==e(t).selected){const l=this.armed?.origin??this.lastPosition;this.armed=null,this.internalTarget=null,this.lastPosition=n.started?i:null;const o=n.enabled?n.started&&null!==l?n.held?"break-starts-held":n.active?"break-active":null:"before-playback":"disabled";if(null!==o||!Number.isFinite(i)||null===l||l===i)return o&&n.enabled&&this.diagnose?.("DA-SNAPBACK-NOT-APPLIED","Seek did not apply VOD snapback",{reason:o}),null;const d=a.filter(e=>{if("number"!=typeof e.start||!Number.isFinite(e.start)||null!==t(e)||!e.controls?.snapback||this.played.has(e.id))return!1;const a=e.start+e.duration;return i>=e.start&&i<a||i>l&&l<e.start&&i>=a}).sort((t,e)=>Number(t.start)-Number(e.start)||(t.id<e.id?-1:t.id>e.id?1:0));for(const t of d){if(!r(t)){this.diagnose?.("DA-SNAPBACK-NOT-APPLIED","Seek crossed an unplayable snapback break",{breakId:t.id,reason:"no-playable-variant"});continue}const e={breakId:t.id,target:Number(t.start),...i>=Number(t.start)+t.duration?{resumePosition:i}:{}};return this.armed={request:e,origin:l,deadline:s+1e3,attempts:1},this.diagnose?.("DA-SNAPBACK-APPLIED","Viewer seek corrected to an unplayed VOD break",{...e}),e}return null}seeked(t,e,i,a,n){return this.armed&&(Math.abs(t-this.armed.request.target)<=.5||Math.abs(t-this.armed.origin)<=.5)||this.isInternal(t,a)?null:this.plan(t,e,i,a,n)}sample(t,e,i,a,n,s){if(!Number.isFinite(t))return{waiting:!1,internal:!1};if(!i.started)return{waiting:!1,internal:!1};if(this.armed){const r=this.armed;if(!e.some(t=>t.id===r.request.breakId)||!i.enabled||i.held)return this.abandon(t,"ineligible"),{waiting:!1,internal:!0};if(Math.abs(t-r.request.target)<=.5)return this.armed=null,this.expectSeek(t,a),{waiting:!1,internal:!0,ready:r.request};if(null!==this.lastPosition&&Math.abs(t-this.lastPosition)>2&&Math.abs(t-r.origin)>.5){const r=this.plan(t,e,i,a,s);return r&&n(r),{waiting:null!==r,internal:!0}}return a>=r.deadline&&(1===r.attempts?(r.attempts++,r.deadline=a+1e3,n(r.request)):this.abandon(t,"seek-did-not-land")),{waiting:null!==this.armed,internal:!0}}const r=this.isInternal(t,a);if(!r&&null!==this.lastPosition&&Math.abs(t-this.lastPosition)>2){const r=this.plan(t,e,i,a,s);if(r)return n(r),{waiting:!0,internal:!0}}return this.lastPosition=t,{waiting:!1,internal:r}}abandon(t,e){this.diagnose?.("DA-SNAPBACK-ABANDONED","VOD snapback correction abandoned",{breakId:this.armed?.request.breakId,reason:e}),this.armed=null,this.lastPosition=t}}
1
+ import{BreakFormat as e,getBreakEvent as t,Timebase as i}from"../types/BreakManifest";import{selectVariantDetailed as s}from"./selectVariant";import{playbackCrossingContext as n}from"./PlaybackCrossing";import{BookmarkStart as a}from"./BookmarkStart";export class SnapbackGuard{constructor(e){this.diagnose=e,this.bookmark=new a,this.played=new Set,this.skipped=new Map,this.lastPosition=null,this.observedPosition=null,this.playbackFrom=null,this.sampledStarts=new Map,this.previousStarts=new Map,this.observedAt=0,this.pendingSeek=null,this.viewerSeeking=!1,this.plannedTarget=null,this.plannedDeadline=0,this.playbackContext=null,this.seekArrival=null,this.internalTarget=null,this.internalDeadline=0,this.armed=null,this.resumeSeekPending=!1,this.resumeStalePosition=null}markPlayed(e){this.played.add(e)}hasPlayed(e){return this.played.has(e)}blocksTuneIn(e,t){return t===i.MEDIATIME&&"number"==typeof e.start&&(this.isSkipped(e.id)||!0===e.controls?.snapback&&this.hasPlayed(e.id))}isSkipped(e){return this.skipped.has(e)}noteSeek(e){Number.isFinite(e)&&(this.pendingSeek=e)}noteViewerSeek(){this.viewerSeeking=!0}blocksSeekStart(e,t){return t===i.MEDIATIME&&(this.isSkipped(e.id)||this.blocksTuneIn(e,t)&&null!==this.seekArrival&&this.seekArrival>=Number(e.start))}sampleArrival(e,t,i){this.previousStarts=this.sampledStarts,this.sampledStarts=new Map(t.flatMap(e=>"number"==typeof e.start?[[e.id,e.start]]:[]));const s=this.isInternal(e,i);this.playbackFrom=null!==this.pendingSeek||this.viewerSeeking||s?null:this.observedPosition;const n=Math.max(0,i-this.observedAt)/1e3,a=null!==this.observedPosition&&Math.abs(e-this.observedPosition)>n+2;this.seekArrival=s?null:this.pendingSeek??(this.viewerSeeking||a?e:null),this.pendingSeek=null,this.viewerSeeking=!1,this.observedPosition=e,this.observedAt=i}crossedStart(e,t){return this.previousStarts.get(t)===e&&null!==this.playbackFrom&&null!==this.observedPosition&&this.playbackFrom<e&&this.observedPosition>=e}captureStart(e,t){this.bookmark.capture(e,t)&&e>.5&&(this.lastPosition=e)}expectSeek(e,t,i=!1){this.resumeSeekPending=i,this.resumeStalePosition=i?this.observedPosition??this.lastPosition:null,this.internalTarget=e,this.internalDeadline=t+2e3,this.lastPosition=e}reset(){this.bookmark.reset(),this.played.clear(),this.skipped.clear(),this.resetPosition()}cancelResumeSeek(){this.resumeSeekPending&&(this.resumeSeekPending=!1,this.internalTarget=null)}resetPosition(){this.playbackContext=null,this.viewerSeeking=!1,this.plannedTarget=null,this.playbackFrom=null,this.sampledStarts.clear(),this.previousStarts.clear(),this.resumeSeekPending=!1,this.observedPosition=null,this.pendingSeek=null,this.seekArrival=null,this.lastPosition=null,this.internalTarget=null,this.armed=null}isInternal(e,t){return null!==this.internalTarget&&(t<=this.internalDeadline&&Math.abs(e-this.internalTarget)<=.5||(this.internalTarget=null,!1))}plan(i,a,r,l,o=e=>null!==s(e).selected){this.playbackContext=null;const h=this.armed?.origin??this.lastPosition;this.plannedTarget=Number.isFinite(i)?i:null,this.plannedDeadline=l+2e3,this.resumeSeekPending=!1,this.armed=null,this.internalTarget=null,this.lastPosition=r.started?i:null;const d=r.enabled?r.started&&null!==h?r.held?"break-starts-held":r.active?"break-active":null:"before-playback":"disabled";if(null!==d||!Number.isFinite(i)||null===h||h===i)return d&&r.enabled&&this.diagnose?.("DA-SNAPBACK-NOT-APPLIED","Seek did not apply VOD snapback",{reason:d}),null;this.skipped.clear();let u=a.filter(e=>!("number"!=typeof e.start||!Number.isFinite(e.start)||null!==t(e)||!e.controls?.snapback)&&(!!this.bookmark.eligible(e,h)&&(h<e.start&&i>=e.start))).sort((e,t)=>Number(e.start)-Number(t.start)||(e.id<t.id?-1:e.id>t.id?1:0));const p=u.filter(e=>h<Number(e.start)&&i>=Number(e.start));if(p.length>0){u=[p[p.length-1]];for(const e of a)"number"==typeof e.start&&h<e.start&&i>=e.start&&this.skipped.set(e.id,e.start)}for(const t of u){if(this.hasPlayed(t.id)){this.skipped.delete(t.id);continue}if(!o(t)){this.diagnose?.("DA-SNAPBACK-NOT-APPLIED","Seek crossed an unplayable snapback break",{breakId:t.id,reason:"no-playable-variant"});continue}const a=(this.selectFormat?this.selectFormat(t):s(t).selected?.format)??e.SINGLE,r=a===e.SINGLE||a===e.LSHAPE_AD,d=h<Number(t.start)&&i>=Number(t.start),u={breakId:t.id,target:Number(t.start),...r&&d?{resumePosition:i}:{}};if(null===this.pendingSeek&&this.crossedStart(Number(t.start),t.id)){const e=n(t,i,a,this.seekArrival);e&&(this.playbackContext={id:t.id,context:e},void 0!==e.snapback?.resumePosition&&(u.resumePosition=e.snapback.resumePosition))}return this.skipped.delete(t.id),this.armed={request:u,origin:h,deadline:l+1e3,attempts:1},this.diagnose?.("DA-SNAPBACK-APPLIED","Viewer seek corrected to an unplayed VOD break",{...u}),u}const k=u.every(e=>this.hasPlayed(e.id))?u[0]:void 0;return k&&this.diagnose?.("DA-SNAPBACK-NOT-APPLIED","Seek did not apply VOD snapback",{reason:"already-played",breakId:k.id}),null}startContext(e,t){const i=this.playbackContext;return this.playbackContext=null,i?.id===e.breakId?i.context:{tuneIn:!1,elapsedSec:0,remainingSec:t,snapback:void 0===e.resumePosition?{}:{resumePosition:e.resumePosition}}}beginPlayback(e,t,i,a){const r=this.selectFormat?this.selectFormat(e):s(e).selected?.format,l=n(e,t,r,this.seekArrival);if(!l)return null;this.playbackContext={id:e.id,context:l};const o={breakId:e.id,target:Number(e.start),...l.snapback};return l.snapback&&t!==o.target?(this.armed={request:o,origin:t,deadline:i+1e3,attempts:1},a(o),{waiting:!0,internal:!0}):{waiting:!1,internal:!1,ready:o}}seeked(e,t,i,s,n){if(this.resumeSeekPending&&s<=this.internalDeadline&&null!==this.internalTarget&&null!==this.resumeStalePosition&&Math.abs(e-this.internalTarget)>.5&&Math.abs(e-this.resumeStalePosition)<=.5)return null;if(this.resumeSeekPending=!1,this.armed&&(Math.abs(e-this.armed.request.target)<=.5||Math.abs(e-this.armed.origin)<=.5))return null;if(this.isInternal(e,s))return null;const a=this.plannedTarget;return this.plannedTarget=null,null!==a&&s<=this.plannedDeadline&&Math.abs(e-a)<=.5?null:(this.noteSeek(e),this.plan(e,t,i,s,n))}sample(e,t,i,s,n,a){if(this.playbackFrom=null,!Number.isFinite(e))return{waiting:!1,internal:!1};if(!i.started)return{waiting:!1,internal:!1};if(this.resumeSeekPending){if(s<=this.internalDeadline&&null!==this.internalTarget&&Math.abs(e-this.internalTarget)>.5)return this.resumeStalePosition=e,{waiting:!1,internal:!0};this.resumeSeekPending=!1}if(this.sampleArrival(e,t,s),this.armed){const r=this.armed;if(!t.some(e=>e.id===r.request.breakId)||!i.enabled&&!this.playbackContext||i.held)return this.abandon(e,"ineligible"),{waiting:!1,internal:!0};if(Math.abs(e-r.request.target)<=.5)return this.armed=null,this.expectSeek(e,s),{waiting:!1,internal:!0,ready:r.request};if(null!==this.lastPosition&&Math.abs(e-this.lastPosition)>2&&Math.abs(e-r.origin)>.5){const r=this.plan(e,t,i,s,a);return r&&n(r),{waiting:null!==r,internal:!0}}return s>=r.deadline&&(1===r.attempts?(r.attempts++,r.deadline=s+1e3,n(r.request)):this.abandon(e,"seek-did-not-land")),{waiting:null!==this.armed,internal:!0}}const r=this.isInternal(e,s);if(!r&&null!==this.lastPosition&&Math.abs(e-this.lastPosition)>2){const r=this.plan(e,t,i,s,a);if(r)return this.playbackContext&&!this.playbackContext.context.snapback?(this.armed=null,{waiting:!1,internal:!1,ready:r}):(n(r),{waiting:!0,internal:!0})}return this.lastPosition=e,{waiting:!1,internal:r}}abandon(e,t){this.diagnose?.("DA-SNAPBACK-ABANDONED","VOD snapback correction abandoned",{breakId:this.armed?.request.breakId,reason:t}),this.armed=null,this.lastPosition=e;for(const[t,i]of this.skipped)e<i&&this.skipped.delete(t)}}
@@ -11,6 +11,8 @@ export interface VastAdCallbacks {
11
11
  onAdStarted?: (podPosition?: number) => void;
12
12
  /** IMA does not drive the ad element's `timeupdate`, so this is the only progress signal for the break countdown. */
13
13
  onAdProgress?: (currentTime: number, duration: number) => void;
14
+ /** A pod creative played to its end, with its full length: the next creative's progress adds to it. */
15
+ onAdCompleted?: (duration: number) => void;
14
16
  onAdFirstQuartile?: () => void;
15
17
  onAdMidpoint?: () => void;
16
18
  onAdThirdQuartile?: () => void;