@dolby-optiview/ads-sdk-core 1.4.0 → 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.
- package/dist/OptiViewAds.d.ts +5 -0
- package/dist/OptiViewAds.js +1 -1
- package/dist/diagnostics/dispatchDiagnostic.d.ts +4 -0
- package/dist/diagnostics/dispatchDiagnostic.js +1 -0
- package/dist/generated/diagnosticCodes.d.ts +118 -3
- package/dist/generated/diagnosticCodes.js +1 -1
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/index.d.ts +11 -4
- package/dist/index.js +1 -1
- package/dist/interfaces/PlayerAdapter.d.ts +27 -0
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +1 -1
- package/dist/services/AdBreakSequencer.d.ts +67 -3
- package/dist/services/AdBreakSequencer.js +1 -1
- package/dist/services/AdPlayerController.d.ts +9 -4
- package/dist/services/AdPlayerController.js +1 -1
- package/dist/services/BookmarkStart.d.ts +12 -0
- package/dist/services/BookmarkStart.js +1 -0
- package/dist/services/BreakScheduler.d.ts +15 -50
- package/dist/services/BreakScheduler.js +1 -1
- package/dist/services/BreakSchedulerTypes.d.ts +11 -1
- package/dist/services/BreakWarningTracker.d.ts +6 -0
- package/dist/services/BreakWarningTracker.js +1 -0
- package/dist/services/ContentEndTracker.d.ts +17 -0
- package/dist/services/ContentEndTracker.js +1 -0
- package/dist/services/ExternalPlaybackGate.d.ts +2 -2
- package/dist/services/ManifestService.d.ts +1 -1
- package/dist/services/ManifestService.js +1 -1
- package/dist/services/PauseAdController.d.ts +9 -5
- package/dist/services/PauseAdController.js +1 -1
- package/dist/services/PlaybackCrossing.d.ts +4 -0
- package/dist/services/PlaybackCrossing.js +1 -0
- package/dist/services/RewindRearm.js +1 -1
- package/dist/services/SchedulerSnapback.d.ts +33 -0
- package/dist/services/SchedulerSnapback.js +1 -0
- package/dist/services/SlateClock.d.ts +22 -0
- package/dist/services/SlateClock.js +1 -0
- package/dist/services/SnapbackGuard.d.ts +70 -0
- package/dist/services/SnapbackGuard.js +1 -0
- package/dist/services/VastAdManager.d.ts +2 -0
- package/dist/services/VastAdManager.js +1 -1
- package/dist/services/adPlayer/AdPlayerCallbacks.d.ts +11 -1
- package/dist/services/adPlayer/AdPlayerEvents.d.ts +21 -1
- package/dist/services/adPlayer/AdPlayerEvents.js +1 -1
- package/dist/services/adPlayer/AdPlayerState.d.ts +14 -0
- package/dist/services/adPlayer/AdPlayerState.js +1 -1
- package/dist/services/adPlayer/AdPreloader.d.ts +8 -1
- package/dist/services/adPlayer/AdPreloader.js +1 -1
- package/dist/services/adPlayer/AdStage.js +1 -1
- package/dist/services/adPlayer/AssetPlayback.d.ts +1 -0
- package/dist/services/adPlayer/AssetPlayback.js +1 -1
- package/dist/services/adPlayer/BreakSequenceDriver.d.ts +7 -1
- package/dist/services/adPlayer/BreakSequenceDriver.js +1 -1
- package/dist/services/adPlayer/ContentResumeCoordinator.d.ts +6 -0
- package/dist/services/adPlayer/ContentResumeCoordinator.js +1 -1
- package/dist/services/adPlayer/PauseAdPresenter.d.ts +10 -9
- package/dist/services/adPlayer/PauseAdPresenter.js +1 -1
- package/dist/services/adPlayer/PauseAdTracking.d.ts +13 -0
- package/dist/services/adPlayer/PauseAdTracking.js +1 -0
- package/dist/services/adPlayer/SharedElementBreakPlayer.d.ts +31 -2
- package/dist/services/adPlayer/SharedElementBreakPlayer.js +1 -1
- package/dist/services/adPlayer/SlateCoordinator.d.ts +58 -0
- package/dist/services/adPlayer/SlateCoordinator.js +1 -0
- package/dist/services/adPlayer/SlatePlayback.d.ts +89 -0
- package/dist/services/adPlayer/SlatePlayback.js +1 -0
- package/dist/services/adPlayer/VariantSelector.d.ts +10 -1
- package/dist/services/adPlayer/VariantSelector.js +1 -1
- package/dist/services/adPlayer/connectPauseAd.js +1 -1
- package/dist/services/adPlayer/presentation/PresentationLayout.d.ts +5 -2
- package/dist/services/adPlayer/presentation/PresentationLayout.js +1 -1
- package/dist/services/breakTiming.d.ts +1 -0
- package/dist/services/breakTiming.js +1 -1
- package/dist/services/checkEventBreak.d.ts +5 -0
- package/dist/services/checkEventBreak.js +1 -0
- package/dist/services/parseBreakManifest.d.ts +2 -1
- package/dist/services/parseBreakManifest.js +1 -1
- package/dist/services/parseVastPause.d.ts +18 -0
- package/dist/services/parseVastPause.js +1 -0
- package/dist/services/resolveLshapeAnchor.d.ts +25 -0
- package/dist/services/resolveLshapeAnchor.js +1 -0
- package/dist/services/resolveResumePoint.d.ts +3 -1
- package/dist/services/resolveResumePoint.js +1 -1
- package/dist/services/resolveSlate.d.ts +25 -0
- package/dist/services/resolveSlate.js +1 -0
- package/dist/services/resolveSlateFill.d.ts +39 -0
- package/dist/services/resolveSlateFill.js +1 -0
- package/dist/services/ssbSwitchSignals.d.ts +3 -0
- package/dist/services/ssbSwitchSignals.js +1 -1
- package/dist/session/AdBreakStatusTracker.d.ts +22 -2
- package/dist/session/AdBreakStatusTracker.js +1 -1
- package/dist/session/BreakLifecycle.js +1 -1
- package/dist/session/ContentPlaybackStart.d.ts +23 -0
- package/dist/session/ContentPlaybackStart.js +1 -0
- package/dist/session/DiscoveredManifestUrl.d.ts +44 -0
- package/dist/session/DiscoveredManifestUrl.js +1 -0
- package/dist/session/ScheduledBreaksTracker.d.ts +22 -0
- package/dist/session/ScheduledBreaksTracker.js +1 -0
- package/dist/session/SessionStarter.d.ts +3 -9
- package/dist/session/SessionStarter.js +1 -1
- package/dist/session/SessionStarterDeps.d.ts +3 -0
- package/dist/session/SessionStarterHelpers.d.ts +22 -1
- package/dist/session/SessionStarterHelpers.js +1 -1
- package/dist/session/SessionState.d.ts +16 -0
- package/dist/session/SessionState.js +1 -1
- package/dist/session/SsbSwitch.js +1 -1
- package/dist/session/buildScheduledBreaks.d.ts +9 -0
- package/dist/session/buildScheduledBreaks.js +1 -0
- package/dist/session/contentSnapback.d.ts +5 -1
- package/dist/session/contentSnapback.js +1 -1
- package/dist/session/scheduledBreaksSession.d.ts +6 -0
- package/dist/session/scheduledBreaksSession.js +1 -0
- package/dist/types/BreakManifest.d.ts +40 -2
- package/dist/types/BreakManifest.js +1 -1
- package/dist/types/Events.d.ts +13 -4
- package/dist/types/OptiViewAdsConfig.d.ts +15 -2
- package/dist/types/ScheduledBreak.d.ts +17 -0
- package/dist/types/ScheduledBreak.js +1 -0
- package/package.json +1 -1
|
@@ -1,15 +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
|
+
import type { SnapbackRequest } from './SnapbackGuard';
|
|
6
7
|
import { type ChainSuccessorChangedCallback } from './BreakChain';
|
|
7
|
-
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';
|
|
8
9
|
export { BreakState, systemClock, type BreakAbortedCallback, type BreakApproachingCallback, type BreakStartCallback, type BreakStartContext, type BreakWarningCallback, type Clock, type SchedulerDiagnose, type TuneInOptions, } from './BreakSchedulerTypes';
|
|
9
10
|
export type { ChainSuccessorChangedCallback } from './BreakChain';
|
|
10
11
|
/** Scheduling cadence; a break is discovered up to one interval late on the tick alone. */
|
|
11
12
|
export declare const TICK_INTERVAL_MS = 250;
|
|
12
13
|
export declare class BreakScheduler {
|
|
14
|
+
scheduleObserver?: ScheduledBreaksObserver | undefined;
|
|
13
15
|
private player;
|
|
14
16
|
private breaks;
|
|
15
17
|
private readonly timing;
|
|
@@ -18,11 +20,6 @@ export declare class BreakScheduler {
|
|
|
18
20
|
private checkInterval;
|
|
19
21
|
/** One-shot re-check armed for a wallclock break due inside the next tick (see {@link nextStartDeadlineMs}). */
|
|
20
22
|
private startDeadline;
|
|
21
|
-
/**
|
|
22
|
-
* Milliseconds until the earliest pending wallclock break, when that is within one tick; null
|
|
23
|
-
* otherwise. Re-derived on every check from the stream's Program Date Time, so a driver that
|
|
24
|
-
* ticks the scheduler itself (native SDKs) can arm the same one-shot the web scheduler arms.
|
|
25
|
-
*/
|
|
26
23
|
nextStartDeadlineMs: number | null;
|
|
27
24
|
private onBreakStart;
|
|
28
25
|
private onBreakApproaching;
|
|
@@ -39,15 +36,12 @@ export declare class BreakScheduler {
|
|
|
39
36
|
private breakWarnings;
|
|
40
37
|
/** Warning thresholds already fired per break, so each `breakWarnings.seconds` value emits once. */
|
|
41
38
|
private breakWarningFired;
|
|
42
|
-
/**
|
|
43
|
-
* Completed break ids. The manifest keeps delivering a completed break, so without this
|
|
44
|
-
* ledger {@link updateManifest} would re-add it as PENDING and replay it (most visibly pre-rolls).
|
|
45
|
-
*/
|
|
46
39
|
private completedBreakIds;
|
|
47
40
|
/** Breaks already reported as DA-BREAK-EVENT-UNSUPPORTED, so a re-poll does not repeat the diagnostic. */
|
|
48
41
|
private unsupportedEventBreakIds;
|
|
49
42
|
/** Wallclock instant content playback ended (spec `event: 'end'`), or null while content plays. */
|
|
50
43
|
private playbackEndedAtMs;
|
|
44
|
+
private readonly contentEnd;
|
|
51
45
|
private debug;
|
|
52
46
|
private readonly chain;
|
|
53
47
|
private tuneInEnabled;
|
|
@@ -70,6 +64,7 @@ export declare class BreakScheduler {
|
|
|
70
64
|
* close to the start the join landed. The minimum keeps the DVR contract: seeking back re-earns it.
|
|
71
65
|
*/
|
|
72
66
|
private minSeenTime;
|
|
67
|
+
private readonly snapback;
|
|
73
68
|
/**
|
|
74
69
|
* Slack for the join discriminator: the first tick lags session start by up to one interval,
|
|
75
70
|
* so a break missed by less than this is boundary noise. A genuine join lands seconds past the start.
|
|
@@ -104,33 +99,18 @@ export declare class BreakScheduler {
|
|
|
104
99
|
updateManifest(manifest: BreakManifest): void;
|
|
105
100
|
applyEventBoundary(manifest: BreakManifest): void;
|
|
106
101
|
setEventBoundaryCheck(check: ((positionMs: number | null) => boolean) | null): void;
|
|
107
|
-
/**
|
|
108
|
-
* Re-align the schedule to a manifest from a different channel (see `detectChannelSwitch`).
|
|
109
|
-
* Every ledger of the previous channel is dropped so a break id reused by the new channel
|
|
110
|
-
* is not suppressed as "already completed". An ACTIVE break is filtered from the new manifest
|
|
111
|
-
* and ends as an early return. Before playback starts the new manifest's `adStartDelay` applies;
|
|
112
|
-
* after it, the delay in effect is kept.
|
|
113
|
-
*/
|
|
114
102
|
resetForChannelSwitch(manifest: BreakManifest): void;
|
|
115
103
|
/**
|
|
116
104
|
* `event: 'start'` and `event: 'end'` are scheduled here (pause breaks are filtered out before).
|
|
117
105
|
* Unknown events are reported once and dropped, so they never fire as timeline breaks.
|
|
118
106
|
*/
|
|
119
107
|
private isSchedulableEvent;
|
|
120
|
-
/**
|
|
121
|
-
* Content playback began (spec `event: 'start'`): pre-rolls may trigger and played time starts
|
|
122
|
-
* counting. Once per session; a replay in the same session does not re-fire pre-rolls.
|
|
123
|
-
*/
|
|
124
108
|
markPlaybackStart(): void;
|
|
125
109
|
setOnAdStartDelayElapsed(callback: (() => void) | null): void;
|
|
126
110
|
holdBreakStarts(): void;
|
|
127
111
|
releaseBreakStarts(): void;
|
|
128
112
|
areBreakStartsHeld(): boolean;
|
|
129
113
|
private notifyAdStartDelayElapsed;
|
|
130
|
-
/**
|
|
131
|
-
* Content playback ended (spec `event: 'end'`): post-rolls may trigger once their `delay` has
|
|
132
|
-
* elapsed on the wallclock. A completed post-roll never replays (see {@link completedBreakIds}).
|
|
133
|
-
*/
|
|
134
114
|
markPlaybackEnd(): void;
|
|
135
115
|
/** Start monitoring for break start times. */
|
|
136
116
|
start(onBreakStart: BreakStartCallback, onBreakApproaching?: BreakApproachingCallback, onBreakWarning?: BreakWarningCallback, onBreakAborted?: BreakAbortedCallback, onBreakSkipped?: (brk: Break, reason: AdBreakSkipReason) => void): void;
|
|
@@ -156,13 +136,17 @@ export declare class BreakScheduler {
|
|
|
156
136
|
setContentReloading(active: boolean): void;
|
|
157
137
|
setStartupSeeking(active: boolean): void;
|
|
158
138
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* 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.
|
|
162
141
|
*/
|
|
163
142
|
notifyResumeSeek(targetSeconds: number, breakEndSeconds: number): void;
|
|
164
143
|
/** A viewer seek through the SDK: drop the resume-seek guard so the step is judged by the normal rules. */
|
|
165
144
|
notifyViewerSeek(): void;
|
|
145
|
+
setSnapbackOnSeek(enabled: boolean, canPlay?: (brk: Break) => boolean, selectFormat?: (brk: Break) => BreakFormat | undefined): void;
|
|
146
|
+
setOnSnapback(callback: ((request: SnapbackRequest) => void) | null): void;
|
|
147
|
+
planSeek(target: number): SnapbackRequest | null;
|
|
148
|
+
notifySeeked(): void;
|
|
149
|
+
private startBreak;
|
|
166
150
|
private skipBreak;
|
|
167
151
|
private cleanupBreaks;
|
|
168
152
|
getActiveBreak(): Break | null;
|
|
@@ -173,35 +157,16 @@ export declare class BreakScheduler {
|
|
|
173
157
|
id: string;
|
|
174
158
|
secondsUntil: number;
|
|
175
159
|
} | null;
|
|
176
|
-
/**
|
|
177
|
-
* Advance the schedule NOW because a cue reached the playhead: the 250ms tick discovers a start
|
|
178
|
-
* ~125ms late on average, an adapter reporting PRESENTATION knows the instant. Runs
|
|
179
|
-
* {@link checkBreaks} so every tick guard applies unchanged, including the timing check against
|
|
180
|
-
* the tracker's media time: a segment-anchored emsg cue presenting at its segment start is ignored.
|
|
181
|
-
* Returns the id of the break this armed, or null.
|
|
182
|
-
*/
|
|
160
|
+
/** Check the schedule on cue presentation. Returns the break armed by this cue, or null. */
|
|
183
161
|
notifyCuePresented(pts: number): string | null;
|
|
184
162
|
private checkBreaks;
|
|
185
|
-
|
|
186
|
-
private checkPreRollBreak;
|
|
187
|
-
/**
|
|
188
|
-
* Post-rolls fire `delay` seconds of wallclock after content playback ended (spec `event: 'end'`).
|
|
189
|
-
* An ad-free start still active at that moment keeps the break pending, not consumed.
|
|
190
|
-
*/
|
|
191
|
-
private checkPostRollBreak;
|
|
192
|
-
/** While true no break may start and consumers must not apply break-driven restrictions (e.g. pause ads). */
|
|
163
|
+
private checkPlaybackEventBreak;
|
|
193
164
|
isAdStartDelayActive(): boolean;
|
|
194
165
|
private getCurrentTime;
|
|
195
166
|
/** Retry rejected callbacks; an accepted preload fires at most once per break. */
|
|
196
167
|
private maybePreload;
|
|
197
168
|
private getBreakStartTime;
|
|
198
|
-
/**
|
|
199
|
-
* Side-effect-free read of the timebase position (no PDT grace bookkeeping, no clock
|
|
200
|
-
* fallback). Used to anchor played time when playback starts.
|
|
201
|
-
*/
|
|
202
|
-
private peekPosition;
|
|
203
169
|
destroy(): void;
|
|
204
|
-
/** Fire configured pre-break warnings, each threshold at most once and only while `secondsUntil > 0`. Pre-rolls excluded. */
|
|
205
170
|
private checkWarnings;
|
|
206
171
|
private log;
|
|
207
172
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{BreakEvent as e,Timebase as t,getBreakEvent as a,getPostRollDelay as i,getPreRollDelay as r,isEventTrigger as s,isPauseBreak as n}from"../types/BreakManifest";import{DEFAULT_BREAK_WARNINGS as d,DEFAULT_PDT_GRACE_SECONDS as h}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 S}from"./breakTiming";import{BreakPreloadLedger as m}from"./BreakPreloadLedger";import{skipBreaksStartedBy as p,soonestBreak as B}from"./ssbStream";import{RewindRearm as E}from"./RewindRearm";import{BreakChain as f}from"./BreakChain";import{BreakState as D,systemClock as I}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=I,r={enabled:!1,minBreakDurationSeconds:0},s=null,n=d,l=null,o=h){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=d,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 f({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=r.enabled,this.minBreakDurationMs=1e3*Math.max(0,r.minBreakDurationSeconds),this.clock=i,this.diagnose=s,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 E({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)}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){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!s(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,r){this.onBreakStart=e,this.onBreakApproaching=t??null,this.onBreakWarning=a??null,this.onBreakAborted=i??null,this.onBreakSkipped=r??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 p(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.contentReloading=e,this.rewind.setContentReloading(e||this.startupSeeking)}setStartupSeeking(e){this.startupSeeking=e,this.rewind.setContentReloading(e||this.contentReloading)}notifyResumeSeek(e,t){this.rewind.notifyResumeSeek(e,t)}notifyViewerSeek(){this.rewind.notifyViewerSeek()}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:S(i,a)}getNextPendingBreak(){return B([...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 r=this.breaks.get(a);return r&&r.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();null!==e&&(this.minSeenTime=null===this.minSeenTime?e:Math.min(this.minSeenTime,e),this.rewind.rearmOnRewind(e));for(const s of this.breaks.values()){if(s.state===D.ACTIVE&&this.timing.timebase===t.WALLCLOCK&&null!==e&&!this.abortNotifiedBreakIds.has(s.break.id)){const t=this.getBreakStartTime(s.break),a=1e3*(s.break.duration??0);null!==t&&a>0&&e>t+a+this.WINDOW_ESCAPE_GRACE_MS&&(this.abortNotifiedBreakIds.add(s.break.id),this.log(`⚠️ Break window escaped: "${s.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:s.break.id,overrunSec:Math.round((e-t-a)/100)/10}),this.onBreakAborted?.(s.break));continue}if(s.state===D.ACTIVE&&this.timing.timebase===t.PTS&&u(this.timing,this.timingDeps,s.break),s.state!==D.PENDING)continue;const n=r(s.break);if(null!==n){this.checkPreRollBreak(s,n,a);continue}const d=i(s.break);if(null!==d){this.checkPostRollBreak(s,d,a);continue}if(null===e)continue;if(a)continue;null!==this.cueTracker&&this.timing.timebase===t.PTS&&this.maybePreload(s.break,"manifest arrival — Anvato flow");const h=this.getBreakStartTime(s.break);if(null===h)continue;const l=e-h,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(s.break,o),o>0&&o<=15&&o%2==0&&this.log(`⏱️ Break "${s.break.id}" starts in ${o}s`),o>0&&o<=this.PRELOAD_AHEAD_SECONDS&&this.maybePreload(s.break,`${o}s ahead`),l>=0&&l<=this.TIMING_TOLERANCE_MS){const e=Math.max(0,l),t=1e3*(s.break.duration??0)-e;if(t<=0){this.log(`⚠️ Break missed: "${s.break.id}" (ended ${Math.round(-t/1e3)}s ago, inside the start tolerance)`),this.skipBreak(s,t>0&&this.tuneInEnabled?"insufficient-remaining":"late"),null===this.cueTracker&&this.breaks.get(s.break.id)===s&&this.retiredBreaks.set(s.break.id,s.break);break}if(null!==this.minSeenTime&&h<this.minSeenTime-this.JOIN_DISCRIMINATOR_SLACK_MS&&(!this.tuneInEnabled||t<this.minBreakDurationMs)){this.log(`⚠️ Break missed: "${s.break.id}" (joined in progress inside the start tolerance, ${Math.round(t/1e3)}s remaining < tune-in rules)`),this.skipBreak(s,t>0&&this.tuneInEnabled?"insufficient-remaining":"late"),null===this.cueTracker&&this.breaks.get(s.break.id)===s&&this.retiredBreaks.set(s.break.id,s.break);break}this.preloadLedger.shouldRetryRejectedAtStart(s.break.id)&&this.maybePreload(s.break,"retrying a rejected preload at break start"),this.log(`🎬 BREAK TRIGGERED: "${s.break.id}"`),s.state=D.ACTIVE,this.onBreakStart&&this.onBreakStart(s.break,{tuneIn:!1,elapsedSec:e/1e3,remainingSec:t/1e3});break}if(l>this.TIMING_TOLERANCE_MS){const t=h+1e3*(s.break.duration??0)-e;if(this.tuneInEnabled&&t>=this.minBreakDurationMs&&t>0){this.log(`🎬 TUNE-IN BREAK TRIGGERED: "${s.break.id}" (${Math.round(t/1e3)}s remaining)`),s.state=D.ACTIVE,this.onBreakStart&&this.onBreakStart(s.break,{tuneIn:!0,elapsedSec:l/1e3,remainingSec:t/1e3});break}this.log(`⚠️ Break missed: "${s.break.id}" (was ${Math.round(l/1e3)}s ago)`),this.skipBreak(s,t>0&&this.tuneInEnabled?"insufficient-remaining":"late"),null===this.cueTracker&&this.breaks.get(s.break.id)===s&&this.retiredBreaks.set(s.break.id,s.break)}}}checkPreRollBreak(e,t,a){if(!this.rewind.playbackStarted)return;const i=1e3*t;if(this.rewind.adStartDelayMs>0&&i<this.rewind.adStartDelayMs)return this.log(`⚠️ Pre-roll skipped: "${e.break.id}" (delay ${t}s < adStartDelay ${this.rewind.adStartDelayMs/1e3}s)`),void this.skipBreak(e,"start-delay");const r=this.rewind.channelPlayedMs,s=Math.round((i-r)/1e3);s>0&&s<=15&&s%2==0&&this.log(`⏱️ Pre-roll "${e.break.id}" starts in ${s}s`),s>0&&s<=this.PRELOAD_AHEAD_SECONDS&&this.maybePreload(e.break,`pre-roll, ${s}s ahead`),r>=i&&!a&&(this.log(`🎬 PRE-ROLL TRIGGERED: "${e.break.id}"${t>0?` (after ${t}s delay)`:""}`),e.state=D.ACTIVE,this.onBreakStart&&this.onBreakStart(e.break,{tuneIn:!1,elapsedSec:0,remainingSec:e.break.duration??0}))}checkPostRollBreak(e,t,a){if(null===this.playbackEndedAtMs)return;const i=1e3*t,r=this.clock.now()-this.playbackEndedAtMs,s=Math.round((i-r)/1e3);s>0&&s<=this.PRELOAD_AHEAD_SECONDS&&this.maybePreload(e.break,`post-roll, ${s}s ahead`),r>=i&&!a&&(this.log(`🎬 POST-ROLL TRIGGERED: "${e.break.id}"${t>0?` (after ${t}s delay)`:""}`),e.state=D.ACTIVE,this.onBreakStart&&this.onBreakStart(e.break,{tuneIn:!1,elapsedSec:0,remainingSec:e.break.duration??0}))}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 s(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.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. */
|
|
@@ -37,6 +43,10 @@ export interface BreakStartContext {
|
|
|
37
43
|
elapsedSec: number;
|
|
38
44
|
/** Effective remaining duration, in seconds, the break should be presented for. */
|
|
39
45
|
remainingSec: number;
|
|
46
|
+
/** VOD seek correction; seek-over resumes at this position, seek-into uses normal policy. */
|
|
47
|
+
snapback?: {
|
|
48
|
+
resumePosition?: number;
|
|
49
|
+
};
|
|
40
50
|
}
|
|
41
51
|
export type BreakStartCallback = (breakInfo: Break, context: BreakStartContext) => void;
|
|
42
52
|
/**
|
|
@@ -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 =
|
|
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
|
|
26
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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`))}}}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Timebase, type Break } from '../types/BreakManifest';
|
|
2
|
+
import type { Clock, SchedulerDiagnose } from './BreakSchedulerTypes';
|
|
3
|
+
import type { TrackedBreak } from './RewindRearm';
|
|
4
|
+
import { SnapbackGuard, type SnapbackRequest } from './SnapbackGuard';
|
|
5
|
+
export declare class SchedulerSnapback {
|
|
6
|
+
private readonly deps;
|
|
7
|
+
readonly guard: SnapbackGuard;
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
canPlay: ((brk: Break) => boolean) | undefined;
|
|
10
|
+
onCorrect: ((request: SnapbackRequest) => void) | null;
|
|
11
|
+
constructor(deps: {
|
|
12
|
+
breaks: Map<string, TrackedBreak>;
|
|
13
|
+
retiredBreaks: Map<string, Break>;
|
|
14
|
+
completedBreakIds: Set<string>;
|
|
15
|
+
clock: Clock;
|
|
16
|
+
diagnose: SchedulerDiagnose | null;
|
|
17
|
+
state: () => {
|
|
18
|
+
timebase: Timebase;
|
|
19
|
+
started: boolean;
|
|
20
|
+
held: boolean;
|
|
21
|
+
active: boolean;
|
|
22
|
+
position: number;
|
|
23
|
+
};
|
|
24
|
+
preload: (brk: Break, label: string) => void;
|
|
25
|
+
skip: (tracked: TrackedBreak) => void;
|
|
26
|
+
});
|
|
27
|
+
private eligibility;
|
|
28
|
+
private breaks;
|
|
29
|
+
private arm;
|
|
30
|
+
planSeek(target: number): SnapbackRequest | null;
|
|
31
|
+
notifySeeked(): void;
|
|
32
|
+
sample(): ReturnType<SnapbackGuard['sample']>;
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
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}}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { BreakFormat, Timebase, type Break } from '../types/BreakManifest';
|
|
2
|
+
import type { SchedulerDiagnose } from './BreakSchedulerTypes';
|
|
3
|
+
import type { BreakStartContext } from './BreakSchedulerTypes';
|
|
4
|
+
import { BookmarkStart } from './BookmarkStart';
|
|
5
|
+
export interface SnapbackRequest {
|
|
6
|
+
breakId: string;
|
|
7
|
+
target: number;
|
|
8
|
+
resumePosition?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface SnapbackEligibility {
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
started: boolean;
|
|
13
|
+
held: boolean;
|
|
14
|
+
active: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** Portable seek policy. All positions are content seconds; deadlines use the injected clock. */
|
|
17
|
+
export declare class SnapbackGuard {
|
|
18
|
+
private readonly diagnose;
|
|
19
|
+
selectFormat: ((brk: Break) => BreakFormat | undefined) | undefined;
|
|
20
|
+
readonly bookmark: BookmarkStart;
|
|
21
|
+
private played;
|
|
22
|
+
private skipped;
|
|
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;
|
|
35
|
+
private internalTarget;
|
|
36
|
+
private internalDeadline;
|
|
37
|
+
private armed;
|
|
38
|
+
constructor(diagnose: SchedulerDiagnose | null);
|
|
39
|
+
markPlayed(id: string): 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;
|
|
52
|
+
reset(): void;
|
|
53
|
+
cancelResumeSeek(): void;
|
|
54
|
+
resetPosition(): void;
|
|
55
|
+
private isInternal;
|
|
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;
|
|
63
|
+
seeked(position: number, breaks: Break[], eligibility: SnapbackEligibility, now: number, canPlay?: (brk: Break) => boolean): SnapbackRequest | null;
|
|
64
|
+
sample(position: number, breaks: Break[], eligibility: SnapbackEligibility, now: number, correct: (request: SnapbackRequest) => void, canPlay?: (brk: Break) => boolean): {
|
|
65
|
+
waiting: boolean;
|
|
66
|
+
internal: boolean;
|
|
67
|
+
ready?: SnapbackRequest;
|
|
68
|
+
};
|
|
69
|
+
private abandon;
|
|
70
|
+
}
|