@dolby-optiview/ads-sdk-adapter-hlsjs 1.0.0-beta.1

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.
@@ -0,0 +1,251 @@
1
+ import Hls from 'hls.js';
2
+ import type { PlayerAdapter, PlayerAdapterEvent, PlayerAdapterEventHandler, VideoQuality } from '@dolby-optiview/ads-sdk-core';
3
+ /**
4
+ * HLS.js adapter implementing the PlayerAdapter interface.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import Hls from 'hls.js';
9
+ * import { HlsJsAdapter } from '@dolby-optiview/ads-sdk-adapter-hlsjs';
10
+ *
11
+ * // Content player (pass existing video element)
12
+ * const video = document.getElementById('video') as HTMLVideoElement;
13
+ * const hls = new Hls();
14
+ * hls.loadSource('https://example.com/stream.m3u8');
15
+ * hls.attachMedia(video);
16
+ * const adapter = new HlsJsAdapter(hls, video);
17
+ *
18
+ * // Ad adapter factory (SDK creates a container div and passes it)
19
+ * createAdAdapter: (container) => {
20
+ * const adVideo = document.createElement('video');
21
+ * container.appendChild(adVideo);
22
+ * const adHls = new Hls();
23
+ * adHls.attachMedia(adVideo);
24
+ * return new HlsJsAdapter(adHls, adVideo);
25
+ * }
26
+ * ```
27
+ */
28
+ export declare class HlsJsAdapter implements PlayerAdapter {
29
+ private hls;
30
+ private video;
31
+ private eventListeners;
32
+ private boundHandlers;
33
+ private _programDateTime;
34
+ private deferredSeekTime;
35
+ /**
36
+ * The level selection in force before {@link pinLowestQuality}, or `null` when nothing is
37
+ * pinned. `-1` is hls.js's own value for automatic, so it is a meaningful snapshot rather
38
+ * than a sentinel — an application that had pinned a level itself gets that level back, not
39
+ * ABR.
40
+ */
41
+ private levelBeforePin;
42
+ /**
43
+ * Cues already emitted as `parsed`, keyed by their pts, awaiting presentation.
44
+ *
45
+ * hls.js hands us a cue at segment PARSE — 20-30s before the playhead reaches it —
46
+ * so the cue and the instant it refers to are two separate events. The `presented`
47
+ * twin is timed off the metadata TextTrack (see attachId3CueEnterListener) but its
48
+ * CONTENT comes from here: the track's own cue payload is hls.js's DataCue shape,
49
+ * and re-deriving the Anvato fields from it would be a second, divergent parse of
50
+ * the same bytes. Keyed by pts because hls.js sets the track cue's `startTime` from
51
+ * `sample.pts` — the identical value this handler reads (id3-track-controller:
52
+ * `const startTime = samples[i].pts`), so the two line up exactly rather than
53
+ * approximately.
54
+ *
55
+ * An array per pts: one ID3 sample can carry several GEOB frames, and they all
56
+ * share a timestamp.
57
+ */
58
+ private parsedCues;
59
+ /** pts already emitted as `presented`, so a re-entered cue fires once. */
60
+ private presentedPts;
61
+ /** The metadata track listener, kept for teardown. */
62
+ private id3TrackListener;
63
+ constructor(hls: Hls, videoElement: HTMLVideoElement);
64
+ /**
65
+ * Current playback time in seconds.
66
+ */
67
+ get currentTime(): number;
68
+ /**
69
+ * Total duration of the content in seconds.
70
+ */
71
+ get duration(): number;
72
+ /**
73
+ * Live per the PLAYLIST, not per the media duration.
74
+ *
75
+ * `video.duration` cannot answer this on hls.js: the engine sets it to Infinity for live
76
+ * only when `liveDurationInfinity` is configured, and that defaults to false — so a live
77
+ * stream normally reports a finite duration equal to the playlist edge. `latestLevelDetails`
78
+ * carries the real answer, taken straight from the absence of `#EXT-X-ENDLIST`.
79
+ *
80
+ * Cast and optional-chained because `latestLevelDetails` is newer than the hls.js this
81
+ * adapter must keep compiling against; an older build returns undefined and the core falls
82
+ * back to its duration proxy.
83
+ */
84
+ get isLive(): boolean | null;
85
+ /**
86
+ * Whether the player is currently paused.
87
+ */
88
+ get paused(): boolean;
89
+ /**
90
+ * Whether the player is muted.
91
+ */
92
+ get muted(): boolean;
93
+ set muted(value: boolean);
94
+ /**
95
+ * Volume level in the range [0, 1].
96
+ */
97
+ get volume(): number;
98
+ set volume(value: number);
99
+ /**
100
+ * Playback rate, `1` being normal speed.
101
+ *
102
+ * Proxies the element rather than remembering what was written. hls.js drives this property
103
+ * itself when `maxLiveSyncPlaybackRate` is configured, nudging the rate to hold live latency,
104
+ * and the SDK needs to see that so it can stop competing rather than fight it.
105
+ */
106
+ get playbackRate(): number;
107
+ set playbackRate(value: number);
108
+ /**
109
+ * The underlying HTMLVideoElement. Exposed so the SDK can pass it to GAM.
110
+ */
111
+ get videoElement(): HTMLVideoElement;
112
+ /**
113
+ * Whether the content and ad pipelines can buffer in parallel. True whenever
114
+ * HLS.js (MSE) is operational. On Web, `false` prevents continued content
115
+ * playout during a break because the two pipelines cannot remain buffered
116
+ * concurrently.
117
+ */
118
+ get supportsParallelBuffering(): boolean;
119
+ /**
120
+ * Program Date Time from the HLS manifest (EXT-X-PROGRAM-DATE-TIME).
121
+ */
122
+ get programDateTime(): Date | null;
123
+ /**
124
+ * Pause playback.
125
+ */
126
+ pause(): void;
127
+ /**
128
+ * Resume playback.
129
+ */
130
+ play(): Promise<void>;
131
+ /**
132
+ * Seek to a specific time in seconds.
133
+ */
134
+ seek(time: number): void;
135
+ /**
136
+ * Load a media source without playing.
137
+ * Used for preloading ad content.
138
+ */
139
+ /**
140
+ * The manifest hls.js currently has, read off the engine (`hls.url`) so it is correct even
141
+ * when the application called `loadSource()` itself. The element's own `src` is a `blob:`
142
+ * MediaSource and cannot be reloaded.
143
+ */
144
+ get sourceUrl(): string | null;
145
+ load(url: string): Promise<void>;
146
+ /**
147
+ * Detached prefetch for the single-decoder preload strategy.
148
+ *
149
+ * Spins up a throwaway HLS.js instance with `startFragPrefetch` that parses
150
+ * the manifest and fetches the first fragment WITHOUT attaching media — so no
151
+ * second MediaSource / decoder is engaged. The raw responses land in the
152
+ * browser HTTP cache; the instance is destroyed once the first fragment is
153
+ * loaded (or on error/timeout). Best-effort: never rejects.
154
+ */
155
+ preload(url: string): Promise<void>;
156
+ /**
157
+ * The closest-to-live position hls.js will actually hold — `null` on VOD.
158
+ *
159
+ * hls.js is the one engine that pre-computes this, so there is no offset arithmetic
160
+ * here: its own `hls.liveSyncPosition` is already `liveEdge - targetLatency`, clamped
161
+ * into the seekable window by the latency controller itself.
162
+ *
163
+ * The explicit liveness gate is NOT redundant, despite what hls.js's own docstring
164
+ * suggests ("@returns null prior to loading live Playlist"). On VOD the getter returns
165
+ * a NUMBER, not null: `targetLatency` falls back to `liveSyncDurationCount * targetduration`
166
+ * (3 x segment duration by default) whenever `holdBack` is absent, which is exactly the
167
+ * VOD case, and `estimateLiveEdge()` needs only `levelDetails` — which VOD has. The
168
+ * result is a plausible-looking position roughly 3 segments from the end of the asset.
169
+ * Handing that to the resume clamp would drag every VOD resume backwards, breaking the
170
+ * one guarantee this feature is supposed to keep: VOD is untouched.
171
+ *
172
+ * `latestLevelDetails.live` is the authoritative flag and is public API.
173
+ */
174
+ get maxLiveSeekPosition(): number | null;
175
+ /**
176
+ * Set the requested video quality, or restore the selection in force before the first set.
177
+ * Returns whether an override was applied or an outstanding override was restored.
178
+ *
179
+ * The lowest rendition is selected through `nextLevel`, which hls.js documents as taking
180
+ * effect without interrupting the fragment being decoded. `currentLevel` would flush the
181
+ * whole buffer and stall the content timeline. The argmin is computed because hls.js does
182
+ * not sort its level array by bitrate first.
183
+ */
184
+ setVideoQuality(quality: VideoQuality | null): boolean;
185
+ /**
186
+ * Subscribe to player events.
187
+ */
188
+ on(event: PlayerAdapterEvent, handler: PlayerAdapterEventHandler): void;
189
+ /**
190
+ * Unsubscribe from player events.
191
+ */
192
+ off(event: PlayerAdapterEvent, handler: PlayerAdapterEventHandler): void;
193
+ /**
194
+ * Clean up resources.
195
+ */
196
+ destroy(): void;
197
+ /**
198
+ * Set up HLS.js event listeners for program date time.
199
+ */
200
+ private setupHlsListeners;
201
+ /**
202
+ * Re-emit a held cue as `presented` when the playhead reaches it.
203
+ *
204
+ * The timing comes from the metadata TextTrack hls.js already maintains
205
+ * (`addTextTrack('metadata', 'id3')`, enabled by the default `enableID3MetadataCues`),
206
+ * via the standard `cuechange` event — the HTML "time marches on" algorithm, which is
207
+ * the same machinery THEOplayer's per-cue `enter` runs on. One listener on the track
208
+ * rather than one per cue: a metadata track accumulates a cue every few seconds for the
209
+ * life of a live stream, and per-cue listeners on that would be an unbounded leak.
210
+ *
211
+ * Note the precision of this path is the browser's, not ours. ADS-285 measures it; if
212
+ * it turns out to be the same ~250ms class as the scheduler's poll, this buys nothing
213
+ * on web and the native adapters carry the feature alone. It cannot make things worse:
214
+ * a cue enters when the playhead is genuinely at it, so this can only ever fire a break
215
+ * ON TIME or not at all, never early.
216
+ */
217
+ private attachId3CueEnterListener;
218
+ /**
219
+ * Drop held cues the playhead has passed.
220
+ *
221
+ * Without this a live stream accumulates every ID3 cue it has ever seen — hours of
222
+ * them. The window is generous because a cue may be parsed well before it presents,
223
+ * and dropping one early costs a `presented` emission; it is bounded by wall time,
224
+ * not by count, so it stays correct if the parse lead grows.
225
+ */
226
+ private pruneParsedCues;
227
+ /**
228
+ * Set up video element event listeners.
229
+ */
230
+ private setupVideoListeners;
231
+ /**
232
+ * Update program date time based on current playback position.
233
+ */
234
+ private updateProgramDateTime;
235
+ /**
236
+ * Set the reported PDT from a reference fragment, anchored to the playhead:
237
+ * `pdt = refFragPdt + (currentTime - refFragStart)`.
238
+ *
239
+ * All PDT update paths (timeupdate interpolation, FRAG_CHANGED,
240
+ * LEVEL_LOADED) MUST share this single affine mapping. On a live stream the
241
+ * playlist window slides (`firstFrag.start` grows), so any path that drops
242
+ * the `- refFragStart` term drifts ahead of the true PDT and then snaps
243
+ * back on the next fragment/playlist update — an oscillation that re-fired
244
+ * completed wallclock breaks and broke the countdown badge (PLAYG-280).
245
+ */
246
+ private anchorProgramDateTime;
247
+ /**
248
+ * Emit an event to all registered handlers.
249
+ */
250
+ private emit;
251
+ }
@@ -0,0 +1 @@
1
+ import e from"hls.js";import{decodeUtf8 as t,parseId3GeobFrames as s}from"@dolby-optiview/ads-sdk-core/id3";export class HlsJsAdapter{constructor(e,t){this.eventListeners=new Map,this.boundHandlers=new Map,this._programDateTime=null,this.deferredSeekTime=null,this.levelBeforePin=null,this.parsedCues=new Map,this.presentedPts=new Set,this.id3TrackListener=null,this.hls=e,this.video=t,this.setupHlsListeners(),this.setupVideoListeners()}get currentTime(){return this.video.currentTime}get duration(){return this.video.duration}get isLive(){const e=this.hls?.latestLevelDetails;return"boolean"==typeof e?.live?e.live:null}get paused(){return this.video.paused}get muted(){return this.video.muted}set muted(e){this.video.muted=e}get volume(){return this.video.volume}set volume(e){this.video.volume=e}get playbackRate(){return this.video.playbackRate}set playbackRate(e){this.video.playbackRate=e}get videoElement(){return this.video}get supportsParallelBuffering(){return e.isSupported()}get programDateTime(){return this._programDateTime}pause(){this.video.pause()}async play(){await this.video.play()}seek(e){this.video.seeking?this.deferredSeekTime=e:this.video.currentTime=e}get sourceUrl(){return this.hls.url??null}async load(t){return new Promise((s,i)=>{const r=()=>{this.hls.off(e.Events.MANIFEST_PARSED,r),this.hls.off(e.Events.ERROR,n),s()},n=(t,s)=>{s.fatal&&(this.hls.off(e.Events.MANIFEST_PARSED,r),this.hls.off(e.Events.ERROR,n),i(new Error(`HLS load error: ${s.details}`)))};this.hls.on(e.Events.MANIFEST_PARSED,r),this.hls.on(e.Events.ERROR,n),this.hls.loadSource(t),this.hls.attachMedia(this.video)})}async preload(t){if(e.isSupported())return new Promise(s=>{const i=new e({startFragPrefetch:!0,autoStartLoad:!0});let r=!1;const n=()=>{if(!r){r=!0,clearTimeout(d),i.off(e.Events.FRAG_LOADED,a),i.off(e.Events.ERROR,o);try{i.destroy()}catch{}s()}},a=()=>n(),o=(e,t)=>{t.fatal&&n()},d=setTimeout(n,8e3);i.on(e.Events.FRAG_LOADED,a),i.on(e.Events.ERROR,o),i.loadSource(t)})}get maxLiveSeekPosition(){const e=this.hls.latestLevelDetails;if(!e?.live)return null;const t=this.hls.liveSyncPosition;return"number"==typeof t&&Number.isFinite(t)?t:null}setVideoQuality(e){if(null===e){if(null===this.levelBeforePin)return!1;const e=this.levelBeforePin;try{return this.hls.nextLevel=e,this.hls.nextLevel!==e?!1:(this.levelBeforePin=null,!0)}catch{return!1}}const t=this.hls.levels;if(!t||t.length<2)return!1;let s=0;for(let e=1;e<t.length;e++)t[e].bitrate<t[s].bitrate&&(s=e);const i=t[s].bitrate;if("number"!=typeof i||!Number.isFinite(i)||i<=0)return!1;const r=null===this.levelBeforePin;let n;r&&(this.levelBeforePin=this.hls.manualLevel);try{this.hls.nextLevel=s,n=this.hls.nextLevel===s}catch{n=!1}return!!n||(r&&(this.levelBeforePin=null),!1)}on(e,t){this.eventListeners.has(e)||this.eventListeners.set(e,new Set),this.eventListeners.get(e).add(t)}off(e,t){const s=this.eventListeners.get(e);s&&s.delete(t)}destroy(){for(const[e,t]of this.boundHandlers)this.video.removeEventListener(e,t);this.boundHandlers.clear(),this.eventListeners.clear(),this.deferredSeekTime=null,this.id3TrackListener&&(this.id3TrackListener.track.removeEventListener("cuechange",this.id3TrackListener.handler),this.id3TrackListener=null),this.parsedCues.clear(),this.presentedPts.clear()}setupHlsListeners(){this.hls.on(e.Events.FRAG_CHANGED,(e,t)=>{t.frag.programDateTime&&this.anchorProgramDateTime(t.frag.programDateTime,t.frag.start)}),this.hls.on(e.Events.LEVEL_LOADED,(e,t)=>{if(t.details.hasProgramDateTime&&t.details.fragments.length>0){const e=t.details.fragments[0];e.programDateTime&&this.anchorProgramDateTime(e.programDateTime,e.start)}}),this.hls.on(e.Events.FRAG_PARSING_METADATA,(e,i)=>{for(const e of i.samples??[]){const i=s(e.data);if(0===i.length){const t={type:"id3",startTime:e.pts,raw:e.data,phase:"parsed"};this.emit("timedmetadata",t);continue}const r=[];for(const s of i){const i={type:"id3",startTime:e.pts,description:s.description,mimeType:s.mimeType,messageData:t(s.data),raw:e.data,phase:"parsed"};r.push(i),this.emit("timedmetadata",i)}this.parsedCues.set(e.pts,r),this.pruneParsedCues()}this.attachId3CueEnterListener()})}attachId3CueEnterListener(){if(this.id3TrackListener)return;const e=this.video.textTracks;if(!e)return;let t;for(let s=0;s<e.length;s++){const i=e[s];if("metadata"===i.kind&&"id3"===i.label){t=i;break}}if(!t||"function"!=typeof t.addEventListener)return;"disabled"===t.mode&&(t.mode="hidden");const s=()=>{const e=t?.activeCues;if(e)for(let t=0;t<e.length;t++){const s=e[t].startTime;if(this.presentedPts.has(s))continue;const i=this.parsedCues.get(s);if(i){this.presentedPts.add(s);for(const e of i)this.emit("timedmetadata",{...e,phase:"presented"})}}};t.addEventListener("cuechange",s),this.id3TrackListener={track:t,handler:s}}pruneParsedCues(){const e=this.video.currentTime-120;for(const t of this.parsedCues.keys())t<e&&(this.parsedCues.delete(t),this.presentedPts.delete(t))}setupVideoListeners(){const e=()=>{this.updateProgramDateTime(),this.emit("timeupdate")},t=()=>{this.emit("ended")},s=()=>{this.emit("error",this.video.error)},i=()=>{if(this.emit("seeked"),null!==this.deferredSeekTime){const e=this.deferredSeekTime;this.deferredSeekTime=null,this.seek(e)}},r=()=>{this.emit("volumechange")},n=()=>{this.emit("waiting")},a=()=>{this.emit("playing")},o=()=>{this.emit("pause")};this.video.addEventListener("timeupdate",e),this.video.addEventListener("ended",t),this.video.addEventListener("error",s),this.video.addEventListener("seeked",i),this.video.addEventListener("volumechange",r),this.video.addEventListener("waiting",n),this.video.addEventListener("playing",a),this.video.addEventListener("pause",o),this.boundHandlers.set("timeupdate",e),this.boundHandlers.set("ended",t),this.boundHandlers.set("error",s),this.boundHandlers.set("seeked",i),this.boundHandlers.set("volumechange",r),this.boundHandlers.set("waiting",n),this.boundHandlers.set("playing",a),this.boundHandlers.set("pause",o)}updateProgramDateTime(){const e=this.hls.levels,t=this.hls.currentLevel;if(t>=0&&e[t]?.details){const s=e[t].details;if(s?.hasProgramDateTime&&s.fragments.length>0){const e=s.fragments[0];e.programDateTime&&this.anchorProgramDateTime(e.programDateTime,e.start)}}}anchorProgramDateTime(e,t){this._programDateTime=new Date(e+1e3*(this.video.currentTime-t))}emit(e,t){const s=this.eventListeners.get(e);if(s)for(const e of s)try{e(t)}catch(e){console.error("[HlsJsAdapter] Event handler error:",e)}}}
@@ -0,0 +1,3 @@
1
+ export { HlsJsAdapter } from './HlsJsAdapter';
2
+ export { parseId3GeobFrames, parseId3GeobBody, decodeUtf8 } from '@dolby-optiview/ads-sdk-core/id3';
3
+ export type { Id3GeobFrame } from '@dolby-optiview/ads-sdk-core/id3';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export{HlsJsAdapter}from"./HlsJsAdapter";export{parseId3GeobFrames,parseId3GeobBody,decodeUtf8}from"@dolby-optiview/ads-sdk-core/id3";
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@dolby-optiview/ads-sdk-adapter-hlsjs",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "HLS.js adapter for OptiView Ads SDK",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc -b tsconfig.build.json",
18
+ "test": "jest",
19
+ "test:watch": "jest --watch",
20
+ "clean": "rm -rf dist"
21
+ },
22
+ "keywords": [
23
+ "dolby",
24
+ "ads",
25
+ "hlsjs",
26
+ "hls.js",
27
+ "video",
28
+ "adapter"
29
+ ],
30
+ "author": "Dolby",
31
+ "license": "MIT",
32
+ "engines": {
33
+ "node": ">=18.0.0"
34
+ },
35
+ "homepage": "https://ads-sdk.xnappet.live",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "sideEffects": false,
40
+ "dependencies": {
41
+ "@dolby-optiview/ads-sdk-core": "1.0.0-beta.1"
42
+ },
43
+ "peerDependencies": {
44
+ "hls.js": "^1.0.0"
45
+ }
46
+ }