@streamlayer/feature-advertisement 0.4.3 → 1.0.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/lib/advertisement.d.ts +46 -3
- package/lib/advertisement.js +216 -23
- package/lib/index.d.ts +10 -3
- package/lib/index.js +6 -0
- package/lib/units/external-ad.d.ts +53 -14
- package/lib/units/external-ad.js +365 -67
- package/lib/units/external-pause-ad.d.ts +32 -0
- package/lib/units/external-pause-ad.js +144 -3
- package/lib/units/gam.d.ts +16 -0
- package/lib/units/gam.js +99 -0
- package/lib/units/parse-vast-extensions.d.ts +17 -0
- package/lib/units/parse-vast-extensions.js +47 -0
- package/lib/units/pause-ad.d.ts +4 -1
- package/lib/units/ssai-ad-break.d.ts +21 -0
- package/lib/units/ssai-ad-break.js +6 -0
- package/lib/units/ssai-creative.d.ts +89 -0
- package/lib/units/ssai-creative.js +131 -0
- package/lib/units/ssai-detector.d.ts +180 -0
- package/lib/units/ssai-detector.js +1150 -0
- package/lib/units/ssai-preload.d.ts +20 -0
- package/lib/units/ssai-preload.js +55 -0
- package/lib/units/ssai-providers.d.ts +117 -0
- package/lib/units/ssai-providers.js +250 -0
- package/lib/units/vast-tracker.d.ts +45 -0
- package/lib/units/vast-tracker.js +57 -0
- package/package.json +9 -7
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type PalNonceManager = {
|
|
2
|
+
getNonce: () => string;
|
|
3
|
+
sendAdClick: () => void;
|
|
4
|
+
sendPlaybackStart: () => void;
|
|
5
|
+
sendPlaybackEnd: () => void;
|
|
6
|
+
};
|
|
7
|
+
export type GamNonceResult = {
|
|
8
|
+
url: URL;
|
|
9
|
+
manager?: PalNonceManager;
|
|
10
|
+
};
|
|
11
|
+
export declare const gam: (url: string, adContainer?: HTMLDivElement) => (() => Promise<{
|
|
12
|
+
url: URL;
|
|
13
|
+
}>) | (() => Promise<{
|
|
14
|
+
url: URL;
|
|
15
|
+
manager: PalNonceManager;
|
|
16
|
+
} | undefined>);
|
package/lib/units/gam.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google PAL nonce manager + ad-tag resolution for programmatic (GAM) VAST requests.
|
|
3
|
+
*
|
|
4
|
+
* Lives here because the VAST fetcher (`units/external-ad.ts`) is its only consumer: this is the
|
|
5
|
+
* request side of an ad tag, not the promotion side. The *composition* of a tag URL from the proto
|
|
6
|
+
* (`processGamAdvertisement` / `selectGamOptions`) stays in `feature-gamification`, next to the
|
|
7
|
+
* `$store` bridge that is its only consumer — so the two feature packages share no code and
|
|
8
|
+
* neither depends on the other.
|
|
9
|
+
*/
|
|
10
|
+
import { safeLocalStorage, safeSessionStorage } from '@streamlayer/sdk-web-storage';
|
|
11
|
+
import { validate as uuidValidate, v5 as uuid5, v4 as uuid4 } from 'uuid';
|
|
12
|
+
const urlIsNotGam = (url) => {
|
|
13
|
+
return url.origin !== 'https://pubads.g.doubleclick.net';
|
|
14
|
+
};
|
|
15
|
+
export const gam = (url, adContainer) => {
|
|
16
|
+
const parsedUrl = (() => {
|
|
17
|
+
try {
|
|
18
|
+
const parsedUrl = new URL(url);
|
|
19
|
+
return parsedUrl;
|
|
20
|
+
}
|
|
21
|
+
catch (_err) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
})();
|
|
25
|
+
// Non-GAM VAST/VMAP URLs carry no GAM ad-request params and need no PAL nonce, so
|
|
26
|
+
// resolve them directly. PAL is only required for pubads.g.doubleclick.net requests —
|
|
27
|
+
// raw-VAST consumers (e.g. injected sidebar / l-bar) must not depend on the Google PAL
|
|
28
|
+
// SDK being loaded, so this must run before touching `goog`.
|
|
29
|
+
if (parsedUrl && urlIsNotGam(parsedUrl)) {
|
|
30
|
+
// eslint-disable-next-line require-await
|
|
31
|
+
return async () => {
|
|
32
|
+
return { url: parsedUrl };
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// @ts-expect-error goog is injected by the externally-loaded Google PAL SDK
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
const pal = (typeof goog === 'undefined' ? undefined : goog.pal);
|
|
38
|
+
if (!pal || !parsedUrl) {
|
|
39
|
+
// eslint-disable-next-line require-await
|
|
40
|
+
return async () => {
|
|
41
|
+
return undefined;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const consentSettings = new pal.ConsentSettings();
|
|
45
|
+
consentSettings.allowStorage = false;
|
|
46
|
+
const nonceLoader = new pal.NonceLoader(consentSettings);
|
|
47
|
+
const generateUrl = async () => {
|
|
48
|
+
try {
|
|
49
|
+
const parsedUrl = new URL(url);
|
|
50
|
+
const request = new pal.NonceRequest();
|
|
51
|
+
let deviceId = safeLocalStorage().getItem('sl-device-id') || uuid4();
|
|
52
|
+
let sessionId = safeSessionStorage().getItem('sl-ad-session-id');
|
|
53
|
+
if (!sessionId) {
|
|
54
|
+
sessionId = uuid4();
|
|
55
|
+
safeSessionStorage().setItem('sl-ad-session-id', sessionId);
|
|
56
|
+
}
|
|
57
|
+
if (!uuidValidate(deviceId)) {
|
|
58
|
+
deviceId = uuid5(`https://${deviceId}`, uuid5.URL);
|
|
59
|
+
}
|
|
60
|
+
if (!parsedUrl.searchParams.get('url')) {
|
|
61
|
+
parsedUrl.searchParams.set('url', 'https://streamlayer.io');
|
|
62
|
+
}
|
|
63
|
+
if (!parsedUrl.searchParams.get('description_url')) {
|
|
64
|
+
parsedUrl.searchParams.set('description_url', 'https://streamlayer.io');
|
|
65
|
+
}
|
|
66
|
+
request.adWillAutoPlay = parsedUrl.searchParams.get('vpa') === 'auto';
|
|
67
|
+
request.adWillPlayMuted = parsedUrl.searchParams.get('vpmute') === '1';
|
|
68
|
+
request.continuousPlayback = true;
|
|
69
|
+
request.descriptionUrl = parsedUrl.searchParams.get('description_url');
|
|
70
|
+
request.iconsSupported = true;
|
|
71
|
+
request.sessionId = deviceId;
|
|
72
|
+
// Player support for VPAID 2.0, OMID 1.0, and SIMID 1.1
|
|
73
|
+
request.supportedApiFrameworks = '2,7,9';
|
|
74
|
+
request.videoHeight = adContainer?.clientHeight ?? 0;
|
|
75
|
+
request.videoWidth = adContainer?.clientWidth ?? 0;
|
|
76
|
+
const nonceManager = await nonceLoader.loadNonceManager(request);
|
|
77
|
+
if (parsedUrl.searchParams.get('is_lat') === '[placeholder]') {
|
|
78
|
+
parsedUrl.searchParams.set('is_lat', '1');
|
|
79
|
+
}
|
|
80
|
+
if (parsedUrl.searchParams.get('rdid') === '[placeholder]') {
|
|
81
|
+
parsedUrl.searchParams.set('rdid', deviceId);
|
|
82
|
+
}
|
|
83
|
+
if (parsedUrl.searchParams.get('correlator') === '[placeholder]') {
|
|
84
|
+
parsedUrl.searchParams.set('correlator', sessionId);
|
|
85
|
+
}
|
|
86
|
+
parsedUrl.searchParams.set('wta', '1');
|
|
87
|
+
parsedUrl.searchParams.set('givn', nonceManager.getNonce());
|
|
88
|
+
return {
|
|
89
|
+
url: parsedUrl,
|
|
90
|
+
manager: nonceManager,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch (adError) {
|
|
94
|
+
console.log('AdsManager could not be started', adError);
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
return generateUrl;
|
|
99
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AdType } from '@streamlayer/sl-eslib/interactive/interactive.common_pb';
|
|
2
|
+
export interface VastCreative {
|
|
3
|
+
adName: string;
|
|
4
|
+
adType: AdType;
|
|
5
|
+
landscapeBackground?: string;
|
|
6
|
+
portraitBackground?: string;
|
|
7
|
+
sponsorLogo?: string;
|
|
8
|
+
ctaLabel?: string;
|
|
9
|
+
}
|
|
10
|
+
interface VastNode {
|
|
11
|
+
name: string;
|
|
12
|
+
value: string | null;
|
|
13
|
+
attributes: Record<string, string>;
|
|
14
|
+
children: VastNode[];
|
|
15
|
+
}
|
|
16
|
+
export declare function parseVastExtension(root: VastNode): VastCreative | undefined;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { AdType } from '@streamlayer/sl-eslib/interactive/interactive.common_pb';
|
|
2
|
+
const MAP_EXTENSION_NAME_AD_TYPE = {
|
|
3
|
+
DoubleBox: AdType.SIDE_BY_SIDE,
|
|
4
|
+
};
|
|
5
|
+
export function parseVastExtension(root) {
|
|
6
|
+
if (!root) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
const child = (node, name) => node.children.find((c) => c.name === name);
|
|
10
|
+
const childrenByName = (node, name) => node.children.filter((c) => c.name === name);
|
|
11
|
+
const urlOf = (node) => child(node, 'Url')?.value ?? '';
|
|
12
|
+
const num = (v) => (v == null ? NaN : Number(v));
|
|
13
|
+
const adName = child(root, 'AdName')?.value ?? '';
|
|
14
|
+
const backgroundsNode = child(root, 'Backgrounds');
|
|
15
|
+
const backgrounds = backgroundsNode
|
|
16
|
+
? childrenByName(backgroundsNode, 'Background').map((bg) => ({
|
|
17
|
+
orientation: bg.attributes.orientation,
|
|
18
|
+
width: num(bg.attributes.width),
|
|
19
|
+
height: num(bg.attributes.height),
|
|
20
|
+
type: bg.attributes.type,
|
|
21
|
+
url: urlOf(bg),
|
|
22
|
+
}))
|
|
23
|
+
: [];
|
|
24
|
+
const logosNode = child(root, 'Logos');
|
|
25
|
+
const logos = logosNode
|
|
26
|
+
? childrenByName(logosNode, 'Logo').map((logo) => ({
|
|
27
|
+
role: logo.attributes.role,
|
|
28
|
+
width: num(logo.attributes.width),
|
|
29
|
+
height: num(logo.attributes.height),
|
|
30
|
+
type: logo.attributes.type,
|
|
31
|
+
url: urlOf(logo),
|
|
32
|
+
}))
|
|
33
|
+
: [];
|
|
34
|
+
const ctaNode = child(root, 'CTA');
|
|
35
|
+
const cta = {
|
|
36
|
+
label: ctaNode ? (child(ctaNode, 'Label')?.value ?? '') : '',
|
|
37
|
+
type: ctaNode ? (child(ctaNode, 'Type')?.value ?? '') : '',
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
adName,
|
|
41
|
+
adType: MAP_EXTENSION_NAME_AD_TYPE[root.name] || AdType.SIDE_BY_SIDE,
|
|
42
|
+
landscapeBackground: backgrounds.find((b) => b.orientation === 'landscape')?.url,
|
|
43
|
+
portraitBackground: backgrounds.find((b) => b.orientation === 'portrait')?.url,
|
|
44
|
+
sponsorLogo: logos.find((l) => l.role === 'header')?.url,
|
|
45
|
+
ctaLabel: cta.label,
|
|
46
|
+
};
|
|
47
|
+
}
|
package/lib/units/pause-ad.d.ts
CHANGED
|
@@ -5,7 +5,10 @@ export type PauseAdLayout = 'left' | 'center' | 'image';
|
|
|
5
5
|
export type PauseAdCard = {
|
|
6
6
|
id: string;
|
|
7
7
|
parentId: string;
|
|
8
|
-
|
|
8
|
+
/** Optional: a video-only card (programmatic VAST, SC-322) has no image. */
|
|
9
|
+
backgroundImageSrc?: string;
|
|
10
|
+
/** Linear MP4 from VAST → renders full-bleed `<video>` instead of the image (SC-322). */
|
|
11
|
+
backgroundVideoSrc?: string;
|
|
9
12
|
layout: PauseAdLayout;
|
|
10
13
|
isInteractive: boolean;
|
|
11
14
|
logoSrc?: string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createSingleStore } from '@streamlayer/sdk-web-interfaces';
|
|
2
|
+
import type { SsaiCreative } from './ssai-creative';
|
|
3
|
+
/**
|
|
4
|
+
* 'ending' = the ad's window closes within the detector's lead (~0.5s): the
|
|
5
|
+
* overlay's cue to play its closing animation so it completes AT the boundary
|
|
6
|
+
* rather than after it. Beacons are unaffected — playback still owns those.
|
|
7
|
+
*/
|
|
8
|
+
export type SsaiAdBreakStatus = 'detecting' | 'ready' | 'ending' | 'ended';
|
|
9
|
+
export type SsaiAdBreakState = {
|
|
10
|
+
creativeId: string;
|
|
11
|
+
creative?: SsaiCreative;
|
|
12
|
+
status: SsaiAdBreakStatus;
|
|
13
|
+
startMs: number;
|
|
14
|
+
durationSec: number;
|
|
15
|
+
};
|
|
16
|
+
export type SsaiAdBreakStore = ReturnType<typeof createSingleStore<SsaiAdBreakState | null>>;
|
|
17
|
+
/**
|
|
18
|
+
* Imperative atom driven directly by the detector (no nanoquery — the trigger
|
|
19
|
+
* is push-based, not request/response).
|
|
20
|
+
*/
|
|
21
|
+
export declare const createSsaiAdBreakStore: () => SsaiAdBreakStore;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createSingleStore } from '@streamlayer/sdk-web-interfaces';
|
|
2
|
+
/**
|
|
3
|
+
* Imperative atom driven directly by the detector (no nanoquery — the trigger
|
|
4
|
+
* is push-based, not request/response).
|
|
5
|
+
*/
|
|
6
|
+
export const createSsaiAdBreakStore = () => createSingleStore(null);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { DEFAULT_CREATIVE_API_BASE, type SsaiCreativeProvider } from './ssai-providers';
|
|
2
|
+
export { DEFAULT_CREATIVE_API_BASE };
|
|
3
|
+
export type SsaiTrackingEvent = {
|
|
4
|
+
event: string;
|
|
5
|
+
url: string;
|
|
6
|
+
};
|
|
7
|
+
export type SsaiCompanion = {
|
|
8
|
+
staticResource: string;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
clickThrough?: string;
|
|
12
|
+
/** Beacons fired when the companion is clicked. May be empty. */
|
|
13
|
+
clickTracking: string[];
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Lifecycle events the SDK fires beacons for, in the order they occur.
|
|
17
|
+
* `impression` is separate: it is tied to the overlay actually being rendered,
|
|
18
|
+
* not to playback position — see `SsaiCreativeCache.fireImpression`.
|
|
19
|
+
*/
|
|
20
|
+
export type SsaiProgressEvent = 'start' | 'firstQuartile' | 'midpoint' | 'thirdQuartile' | 'complete';
|
|
21
|
+
export type SsaiCreative = {
|
|
22
|
+
creativeId: string;
|
|
23
|
+
advertiser: string;
|
|
24
|
+
title: string;
|
|
25
|
+
iconUrl?: string;
|
|
26
|
+
clickThroughUrl?: string;
|
|
27
|
+
trackingEvents: SsaiTrackingEvent[];
|
|
28
|
+
companion?: SsaiCompanion;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Per-instance cache + impression-firing wrapper around a SsaiCreativeProvider.
|
|
32
|
+
* Caches resolved creatives by creativeId so repeated active-ad polls and pod
|
|
33
|
+
* repeats don't refetch. In-flight fetches are cancellable via AbortController.
|
|
34
|
+
*/
|
|
35
|
+
export declare class SsaiCreativeCache {
|
|
36
|
+
private provider;
|
|
37
|
+
private cache;
|
|
38
|
+
private inflight;
|
|
39
|
+
constructor(provider: SsaiCreativeProvider);
|
|
40
|
+
/**
|
|
41
|
+
* The already-resolved creative, or undefined — synchronous on purpose: a
|
|
42
|
+
* prefetched creative lets the ad break flip straight to 'ready' with no
|
|
43
|
+
* async gap (which the UI would render as a blink between two ads of a pod).
|
|
44
|
+
*/
|
|
45
|
+
peek: (creativeId: string) => SsaiCreative | undefined;
|
|
46
|
+
resolve: (creativeId: string) => Promise<SsaiCreative>;
|
|
47
|
+
/**
|
|
48
|
+
* Cancel an in-flight fetch (e.g. the break ended before it resolved).
|
|
49
|
+
*/
|
|
50
|
+
cancel: (creativeId: string) => void;
|
|
51
|
+
/**
|
|
52
|
+
* Fire the impression for a creative. Delegates to the provider's
|
|
53
|
+
* `fireImpression` when present, otherwise the shared default (first
|
|
54
|
+
* trackingEvent named 'impression' via sendBeacon/fetch). Called once per
|
|
55
|
+
* visible appearance of the bar (the UI dedupes the ~250ms active-poll so a
|
|
56
|
+
* single continuous appearance fires exactly one impression). Each genuine pod
|
|
57
|
+
* insertion of the same creative is a distinct impression and re-fires.
|
|
58
|
+
*/
|
|
59
|
+
fireImpression: (creative: SsaiCreative) => void;
|
|
60
|
+
/**
|
|
61
|
+
* Fire the impression for an already-resolved creativeId. No-op if the
|
|
62
|
+
* creative isn't cached yet (the bar only renders once resolved, so by the
|
|
63
|
+
* time the UI calls this the creative is present).
|
|
64
|
+
*/
|
|
65
|
+
fireImpressionById: (creativeId: string) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Fire a lifecycle / click beacon. Delegates to the provider's `fireTracking`
|
|
68
|
+
* when it defines one, otherwise fires every matching `trackingEvents` URL.
|
|
69
|
+
*/
|
|
70
|
+
fireTracking: (creative: SsaiCreative, event: string) => void;
|
|
71
|
+
/**
|
|
72
|
+
* Fire a beacon for a creativeId that may still be resolving.
|
|
73
|
+
*
|
|
74
|
+
* `start` in particular lands the instant the break opens, which is usually
|
|
75
|
+
* BEFORE the metadata request has come back — dropping it would silently lose
|
|
76
|
+
* the first beacon of every ad. Resolution is already in flight by then and
|
|
77
|
+
* `resolve` returns that same promise, so this attaches to it rather than
|
|
78
|
+
* issuing a second request. A creative that never resolves fires nothing,
|
|
79
|
+
* which is the correct outcome: there are no URLs to fire.
|
|
80
|
+
*/
|
|
81
|
+
fireTrackingById: (creativeId: string, event: string) => void;
|
|
82
|
+
/**
|
|
83
|
+
* Fire the companion's `clickTracking` beacons. Separate from
|
|
84
|
+
* {@link fireTracking} because these live on the companion, not in
|
|
85
|
+
* `trackingEvents` — a provider's `fireTracking` override has no say over them.
|
|
86
|
+
*/
|
|
87
|
+
fireCompanionClickById: (creativeId: string) => void;
|
|
88
|
+
destroy: () => void;
|
|
89
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { fireBeacon, fireDefaultImpression, fireTrackingEvents, DEFAULT_CREATIVE_API_BASE, } from './ssai-providers';
|
|
2
|
+
export { DEFAULT_CREATIVE_API_BASE };
|
|
3
|
+
/**
|
|
4
|
+
* Per-instance cache + impression-firing wrapper around a SsaiCreativeProvider.
|
|
5
|
+
* Caches resolved creatives by creativeId so repeated active-ad polls and pod
|
|
6
|
+
* repeats don't refetch. In-flight fetches are cancellable via AbortController.
|
|
7
|
+
*/
|
|
8
|
+
export class SsaiCreativeCache {
|
|
9
|
+
provider;
|
|
10
|
+
cache = new Map();
|
|
11
|
+
inflight = new Map();
|
|
12
|
+
constructor(provider) {
|
|
13
|
+
this.provider = provider;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The already-resolved creative, or undefined — synchronous on purpose: a
|
|
17
|
+
* prefetched creative lets the ad break flip straight to 'ready' with no
|
|
18
|
+
* async gap (which the UI would render as a blink between two ads of a pod).
|
|
19
|
+
*/
|
|
20
|
+
peek = (creativeId) => this.cache.get(creativeId);
|
|
21
|
+
resolve = (creativeId) => {
|
|
22
|
+
const cached = this.cache.get(creativeId);
|
|
23
|
+
if (cached) {
|
|
24
|
+
return Promise.resolve(cached);
|
|
25
|
+
}
|
|
26
|
+
const existing = this.inflight.get(creativeId);
|
|
27
|
+
if (existing) {
|
|
28
|
+
return existing.promise;
|
|
29
|
+
}
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const promise = this.provider
|
|
32
|
+
.resolve(creativeId, controller.signal)
|
|
33
|
+
.then((creative) => {
|
|
34
|
+
this.cache.set(creativeId, creative);
|
|
35
|
+
this.inflight.delete(creativeId);
|
|
36
|
+
return creative;
|
|
37
|
+
})
|
|
38
|
+
.catch((error) => {
|
|
39
|
+
this.inflight.delete(creativeId);
|
|
40
|
+
throw error;
|
|
41
|
+
});
|
|
42
|
+
this.inflight.set(creativeId, { promise, controller });
|
|
43
|
+
return promise;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Cancel an in-flight fetch (e.g. the break ended before it resolved).
|
|
47
|
+
*/
|
|
48
|
+
cancel = (creativeId) => {
|
|
49
|
+
const existing = this.inflight.get(creativeId);
|
|
50
|
+
if (existing) {
|
|
51
|
+
existing.controller.abort();
|
|
52
|
+
this.inflight.delete(creativeId);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Fire the impression for a creative. Delegates to the provider's
|
|
57
|
+
* `fireImpression` when present, otherwise the shared default (first
|
|
58
|
+
* trackingEvent named 'impression' via sendBeacon/fetch). Called once per
|
|
59
|
+
* visible appearance of the bar (the UI dedupes the ~250ms active-poll so a
|
|
60
|
+
* single continuous appearance fires exactly one impression). Each genuine pod
|
|
61
|
+
* insertion of the same creative is a distinct impression and re-fires.
|
|
62
|
+
*/
|
|
63
|
+
fireImpression = (creative) => {
|
|
64
|
+
if (this.provider.fireImpression) {
|
|
65
|
+
this.provider.fireImpression(creative);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
fireDefaultImpression(creative);
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Fire the impression for an already-resolved creativeId. No-op if the
|
|
72
|
+
* creative isn't cached yet (the bar only renders once resolved, so by the
|
|
73
|
+
* time the UI calls this the creative is present).
|
|
74
|
+
*/
|
|
75
|
+
fireImpressionById = (creativeId) => {
|
|
76
|
+
const creative = this.cache.get(creativeId);
|
|
77
|
+
if (creative) {
|
|
78
|
+
this.fireImpression(creative);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Fire a lifecycle / click beacon. Delegates to the provider's `fireTracking`
|
|
83
|
+
* when it defines one, otherwise fires every matching `trackingEvents` URL.
|
|
84
|
+
*/
|
|
85
|
+
fireTracking = (creative, event) => {
|
|
86
|
+
if (this.provider.fireTracking) {
|
|
87
|
+
this.provider.fireTracking(creative, event);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
fireTrackingEvents(creative, event);
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Fire a beacon for a creativeId that may still be resolving.
|
|
94
|
+
*
|
|
95
|
+
* `start` in particular lands the instant the break opens, which is usually
|
|
96
|
+
* BEFORE the metadata request has come back — dropping it would silently lose
|
|
97
|
+
* the first beacon of every ad. Resolution is already in flight by then and
|
|
98
|
+
* `resolve` returns that same promise, so this attaches to it rather than
|
|
99
|
+
* issuing a second request. A creative that never resolves fires nothing,
|
|
100
|
+
* which is the correct outcome: there are no URLs to fire.
|
|
101
|
+
*/
|
|
102
|
+
fireTrackingById = (creativeId, event) => {
|
|
103
|
+
const cached = this.cache.get(creativeId);
|
|
104
|
+
if (cached) {
|
|
105
|
+
this.fireTracking(cached, event);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.resolve(creativeId)
|
|
109
|
+
.then((creative) => this.fireTracking(creative, event))
|
|
110
|
+
.catch(() => undefined);
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Fire the companion's `clickTracking` beacons. Separate from
|
|
114
|
+
* {@link fireTracking} because these live on the companion, not in
|
|
115
|
+
* `trackingEvents` — a provider's `fireTracking` override has no say over them.
|
|
116
|
+
*/
|
|
117
|
+
fireCompanionClickById = (creativeId) => {
|
|
118
|
+
const companion = this.cache.get(creativeId)?.companion;
|
|
119
|
+
if (!companion) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const url of companion.clickTracking) {
|
|
123
|
+
fireBeacon(url);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
destroy = () => {
|
|
127
|
+
this.inflight.forEach((entry) => entry.controller.abort());
|
|
128
|
+
this.inflight.clear();
|
|
129
|
+
this.cache.clear();
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { SsaiProgressEvent } from './ssai-creative';
|
|
2
|
+
export type SsaiDateRange = {
|
|
3
|
+
id: string;
|
|
4
|
+
creativeId: string;
|
|
5
|
+
startMs: number;
|
|
6
|
+
durationSec: number;
|
|
7
|
+
endMs: number;
|
|
8
|
+
/**
|
|
9
|
+
* Exact program times of lifecycle events, when the stream's tracking markers
|
|
10
|
+
* state them (Synamedia dialect). `updateProgress` prefers these over fractions
|
|
11
|
+
* of the window — the markers are the ad server's own quartile schedule, and
|
|
12
|
+
* they stay honest even while the window's end is still provisional.
|
|
13
|
+
*/
|
|
14
|
+
progressAtMs?: Partial<Record<SsaiProgressEvent, number>>;
|
|
15
|
+
};
|
|
16
|
+
/** hls.js `Level`, structurally: `uri` is a string, legacy `url` a string[]. */
|
|
17
|
+
type HlsLikeLevel = {
|
|
18
|
+
uri?: string;
|
|
19
|
+
url?: string | string[];
|
|
20
|
+
} & Record<string, unknown>;
|
|
21
|
+
export type HlsLike = {
|
|
22
|
+
on: (event: string, handler: (...args: unknown[]) => void) => void;
|
|
23
|
+
off: (event: string, handler: (...args: unknown[]) => void) => void;
|
|
24
|
+
media?: HTMLVideoElement | null;
|
|
25
|
+
/**
|
|
26
|
+
* hls.js' level list and level selection, used only to resolve the active
|
|
27
|
+
* media playlist's URL for the supplemental live-edge poll (see
|
|
28
|
+
* `LIVE_EDGE_POLL_MS`). All optional — a minimal host player simply gets no
|
|
29
|
+
* supplement.
|
|
30
|
+
*/
|
|
31
|
+
levels?: HlsLikeLevel[] | null;
|
|
32
|
+
currentLevel?: number;
|
|
33
|
+
loadLevel?: number;
|
|
34
|
+
/**
|
|
35
|
+
* hls.js' own PROGRAM-DATE-TIME of the frame on screen right now. When the host
|
|
36
|
+
* exposes it this is the best answer available — hls.js derives it from the
|
|
37
|
+
* playlist it is actually playing, so it needs no origin of ours and cannot
|
|
38
|
+
* drift as the live window slides.
|
|
39
|
+
*/
|
|
40
|
+
playingDate?: Date | null;
|
|
41
|
+
/**
|
|
42
|
+
* hls.js live-latency diagnostics. Never used for detection — only surfaced by
|
|
43
|
+
* `getDebugSnapshot` so the debug window can cross-check the program clock
|
|
44
|
+
* against the player's own view of how far behind live it is. All optional:
|
|
45
|
+
* a minimal host player (or the e2e stub) simply reports them as unavailable.
|
|
46
|
+
*/
|
|
47
|
+
latency?: number;
|
|
48
|
+
drift?: number;
|
|
49
|
+
liveSyncPosition?: number | null;
|
|
50
|
+
targetLatency?: number | null;
|
|
51
|
+
};
|
|
52
|
+
export type SsaiDetectorOptions = {
|
|
53
|
+
player?: HlsLike;
|
|
54
|
+
videoRef?: HTMLVideoElement;
|
|
55
|
+
manifestUrl?: string;
|
|
56
|
+
/**
|
|
57
|
+
* A creative id was seen in the manifest for the first time — typically
|
|
58
|
+
* seconds before its break starts, since the viewer's playhead trails the
|
|
59
|
+
* live edge. This is the prefetch hook: resolve the creative and warm its
|
|
60
|
+
* artwork here and the bar can show the instant the break begins.
|
|
61
|
+
*/
|
|
62
|
+
onAdDiscovered?: (creativeId: string) => void;
|
|
63
|
+
onBreakStart: (range: SsaiDateRange) => void;
|
|
64
|
+
onCreativeChange: (range: SsaiDateRange) => void;
|
|
65
|
+
/**
|
|
66
|
+
* The active ad's window ends within `AD_ENDING_LEAD_MS`. Fired once per
|
|
67
|
+
* airing while a break is active — the UI's cue to start its closing
|
|
68
|
+
* animation early enough to complete at the boundary. Purely presentational:
|
|
69
|
+
* beacons and break bookkeeping are unaffected.
|
|
70
|
+
*/
|
|
71
|
+
onAdEnding?: (range: SsaiDateRange) => void;
|
|
72
|
+
onBreakEnd: () => void;
|
|
73
|
+
/**
|
|
74
|
+
* Playback reached a lifecycle milestone of the given ad. Fires at most once
|
|
75
|
+
* per event per playthrough, in order, and is INDEPENDENT of whether the
|
|
76
|
+
* overlay is on screen — the ad is stitched into the stream, so it plays (and
|
|
77
|
+
* must be reported) whether or not the viewer dismissed the bar.
|
|
78
|
+
*/
|
|
79
|
+
onProgress?: (range: SsaiDateRange, event: SsaiProgressEvent) => void;
|
|
80
|
+
};
|
|
81
|
+
/** One `X-AD-EVENT-TYPE` marker: an instant in program time, not a window. */
|
|
82
|
+
export type SsaiTrackingMarker = {
|
|
83
|
+
id: string;
|
|
84
|
+
adId: string;
|
|
85
|
+
event: string;
|
|
86
|
+
timeMs: number;
|
|
87
|
+
/** Per-session beacon from `X-AD-URL`, kept for diagnostics. */
|
|
88
|
+
url?: string;
|
|
89
|
+
};
|
|
90
|
+
/** A `com.synamedia.dai.adbreak` range — the whole break, with no ad identity. */
|
|
91
|
+
type SsaiAdBreakWindow = {
|
|
92
|
+
id: string;
|
|
93
|
+
startMs: number;
|
|
94
|
+
endMs: number;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Reconstruct one ad window per AIRING of an `X-AD-ID` from its tracking
|
|
98
|
+
* markers: earliest marker (impression/start) to latest (complete).
|
|
99
|
+
*
|
|
100
|
+
* `breaks` separates re-airings of the same creative (see above) and is the
|
|
101
|
+
* fallback end: until the `complete` marker shows up, an airing whose markers
|
|
102
|
+
* all share one timestamp would fold to a zero-length window and never display;
|
|
103
|
+
* when an enclosing adbreak range covers it, its end is used instead, and the
|
|
104
|
+
* window tightens on the refresh that brings `complete` in.
|
|
105
|
+
*
|
|
106
|
+
* Exported for unit tests only — production code reaches it through the
|
|
107
|
+
* detector.
|
|
108
|
+
*/
|
|
109
|
+
export declare const foldTrackingMarkers: (markers: SsaiTrackingMarker[], breaks: SsaiAdBreakWindow[]) => SsaiDateRange[];
|
|
110
|
+
/** Which source `currentProgramTimeMs()` is answering from right now. */
|
|
111
|
+
export type SsaiClockSource = 'playingDate' | 'pdt-origin' | 'waiting-for-pdt' | 'wall-clock';
|
|
112
|
+
export type SsaiDebugEvent = {
|
|
113
|
+
wallMs: number;
|
|
114
|
+
/** Program time at the moment of the event (NaN while the clock is unmapped). */
|
|
115
|
+
programMs: number;
|
|
116
|
+
type: string;
|
|
117
|
+
detail?: string;
|
|
118
|
+
};
|
|
119
|
+
export type SsaiDebugRange = SsaiDateRange & {
|
|
120
|
+
/** 'daterange' = explicit creative DATERANGE; 'markers' = window folded from tracking markers. */
|
|
121
|
+
source: 'daterange' | 'markers';
|
|
122
|
+
};
|
|
123
|
+
export type SsaiDebugSnapshot = {
|
|
124
|
+
wallMs: number;
|
|
125
|
+
/** Program time of the on-screen frame per the detector's clock (NaN while unmapped). */
|
|
126
|
+
programMs: number;
|
|
127
|
+
clockSource: SsaiClockSource;
|
|
128
|
+
/** How the detector is attached: hls.js events, native manifest polling, or nothing. */
|
|
129
|
+
attachMode: 'hls' | 'manifest-poll' | 'none';
|
|
130
|
+
/** hls.js `playingDate`, NaN when the host doesn't expose it. */
|
|
131
|
+
playingDateMs: number;
|
|
132
|
+
/** The alternate mapping `programTimeOriginMs + video.currentTime` — cross-check against playingDate. */
|
|
133
|
+
pdtOriginProgramMs: number;
|
|
134
|
+
programTimeOriginMs: number;
|
|
135
|
+
hls: {
|
|
136
|
+
latencySec: number;
|
|
137
|
+
driftSec: number;
|
|
138
|
+
liveSyncPositionSec: number;
|
|
139
|
+
targetLatencySec: number;
|
|
140
|
+
} | null;
|
|
141
|
+
video: {
|
|
142
|
+
currentTimeSec: number;
|
|
143
|
+
bufferedEndSec: number;
|
|
144
|
+
seekableEndSec: number;
|
|
145
|
+
paused: boolean;
|
|
146
|
+
playbackRate: number;
|
|
147
|
+
readyState: number;
|
|
148
|
+
} | null;
|
|
149
|
+
/** Every ad window the detector knows, sorted by start. */
|
|
150
|
+
ranges: SsaiDebugRange[];
|
|
151
|
+
markerCount: number;
|
|
152
|
+
adBreakCount: number;
|
|
153
|
+
breakActive: boolean;
|
|
154
|
+
breakSuppressed: boolean;
|
|
155
|
+
activeRangeId: string | null;
|
|
156
|
+
activeCreativeId: string | null;
|
|
157
|
+
progress: {
|
|
158
|
+
rangeId: string;
|
|
159
|
+
creativeId: string;
|
|
160
|
+
fired: SsaiProgressEvent[];
|
|
161
|
+
} | null;
|
|
162
|
+
/** Ring buffer (newest last, capped) of detector edges: discovery, drops, start/end, beacons. */
|
|
163
|
+
events: SsaiDebugEvent[];
|
|
164
|
+
};
|
|
165
|
+
export type SsaiDetector = {
|
|
166
|
+
destroy: () => void;
|
|
167
|
+
/**
|
|
168
|
+
* Mark the currently active break as user-dismissed: the detector keeps
|
|
169
|
+
* tracking it (so it knows when it ends) but stops re-showing the bar on
|
|
170
|
+
* creative changes until the break ends. A subsequent break shows again.
|
|
171
|
+
*/
|
|
172
|
+
dismissActiveBreak: () => void;
|
|
173
|
+
/**
|
|
174
|
+
* Read-only timing introspection for the debug window. Safe to call at any
|
|
175
|
+
* frequency; never affects detection.
|
|
176
|
+
*/
|
|
177
|
+
getDebugSnapshot: () => SsaiDebugSnapshot;
|
|
178
|
+
};
|
|
179
|
+
export declare const createSsaiDetector: (options: SsaiDetectorOptions) => SsaiDetector;
|
|
180
|
+
export {};
|