@streamlayer/feature-advertisement 0.4.3 → 1.1.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.
@@ -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>);
@@ -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
+ }
@@ -0,0 +1,49 @@
1
+ import { type Question } from '@streamlayer/sl-eslib/interactive/interactive.common_pb';
2
+ export type PauseAdLayout = 'left' | 'center' | 'image';
3
+ /**
4
+ * Which full-bleed renderer draws the card. Absent === `'standard'`.
5
+ *
6
+ * - `'standard'` — the Standard/Image pause ad (promotion card).
7
+ * - `'insight'` — the Insight Pause Ad (factoid card): sponsor logo, heading and a
8
+ * single-column body beside an optional media image (SC-353).
9
+ */
10
+ export type PauseAdKind = 'standard' | 'insight';
11
+ /** How the Insight media image is cropped — mirrors Studio's Media switcher. */
12
+ export type PauseAdMediaMode = 'image' | 'circle' | 'none';
13
+ /**
14
+ * Insight body cap (SC-353). Studio enforces it on the Additional Content field; the
15
+ * SDK clamps as well so a card authored before the limit landed cannot overflow the
16
+ * screen — the format is explicitly designed to fit without scrolling.
17
+ */
18
+ export declare const INSIGHT_BODY_MAX_LENGTH = 600;
19
+ export type PauseAdCard = {
20
+ id: string;
21
+ parentId: string;
22
+ /** Optional: a video-only card (programmatic VAST, SC-322) has no image. */
23
+ backgroundImageSrc?: string;
24
+ /** Linear MP4 from VAST → renders full-bleed `<video>` instead of the image (SC-322). */
25
+ backgroundVideoSrc?: string;
26
+ layout: PauseAdLayout;
27
+ isInteractive: boolean;
28
+ logoSrc?: string;
29
+ title?: string;
30
+ description?: string;
31
+ ctaLabel?: string;
32
+ ctaUrl?: string;
33
+ qrCodeUrl?: string;
34
+ qrCodeLabel?: string;
35
+ /** Absent === `'standard'`. */
36
+ kind?: PauseAdKind;
37
+ /** Insight only: the Media image (`FactoidOptions.image`). */
38
+ mediaSrc?: string;
39
+ /** Insight only: how `mediaSrc` is cropped. */
40
+ mediaMode?: PauseAdMediaMode;
41
+ };
42
+ /**
43
+ * The `pauseAdList` response, shaped for the renderers: keep the cards the SDK has chrome for,
44
+ * turn each into a `PauseAdCard`, drop the ones that cannot be drawn.
45
+ *
46
+ * Pure on purpose — `pause-ad.ts` owns the store and the transport, this owns the card shapes,
47
+ * and only this half needs unit tests.
48
+ */
49
+ export declare const parsePauseAdCards: (questions: Question[]) => PauseAdCard[];
@@ -0,0 +1,130 @@
1
+ import { CardFormat, FactoidMediaMode, NotificationSponsorLogoMode, PauseAdLayout as PauseAdLayoutProto, PromotionButtonType, PromotionFormat, QRCode, UseGeneralLogo, UserAccountInfo, } from '@streamlayer/sl-eslib/interactive/interactive.common_pb';
2
+ /**
3
+ * Insight body cap (SC-353). Studio enforces it on the Additional Content field; the
4
+ * SDK clamps as well so a card authored before the limit landed cannot overflow the
5
+ * screen — the format is explicitly designed to fit without scrolling.
6
+ */
7
+ export const INSIGHT_BODY_MAX_LENGTH = 600;
8
+ /**
9
+ * Sponsor logo for a factoid card. Same General-tab opt-in as the promotion path below,
10
+ * but the logo lives on `question.promotion.sponsor` (a factoid card carries no
11
+ * `PromotionOptions`) and Studio writes the uploaded file to `logo`, not `centerLogo`.
12
+ *
13
+ * `logo` therefore WINS over `centerLogo`: the latter is an optional field carried over from the
14
+ * notification/header surfaces, so a card that kept a stale `centerLogo` from an earlier revision
15
+ * would otherwise render it instead of the Insight logo the author just uploaded.
16
+ */
17
+ const resolveInsightSponsorLogo = (question) => {
18
+ const sponsor = question.promotion?.sponsor;
19
+ if (sponsor?.logoPosition === NotificationSponsorLogoMode.NOTIFICATION_SPONSOR_LOGO_NONE) {
20
+ return undefined;
21
+ }
22
+ return sponsor?.useGeneralLogo === UseGeneralLogo.ENABLED
23
+ ? question.promotion?.generalTabMetadata?.advertiserTransparentLogo || undefined
24
+ : sponsor?.logo || sponsor?.centerLogo || undefined;
25
+ };
26
+ const MEDIA_MODE_BY_PROTO = {
27
+ [FactoidMediaMode.IMAGE]: 'image',
28
+ [FactoidMediaMode.CIRCLE]: 'circle',
29
+ };
30
+ /**
31
+ * Insight Pause Ad, Full Bleed (SC-353). A factoid card, so none of the promotion
32
+ * plumbing above applies: the heading is the question subject, the body is
33
+ * `FactoidOptions.body`, and the sponsor logo comes off `question.promotion.sponsor`.
34
+ *
35
+ * `FACTOID_MEDIA_MODE_VIDEO` is deliberately treated as "no media": the full-bleed
36
+ * format has no player chrome and no way to start playback, unlike the sidebar variant.
37
+ */
38
+ const parseInsightPauseAdCard = (question) => {
39
+ const factoidOptions = question.options?.options.case === 'factoid' ? question.options.options.value : undefined;
40
+ if (!factoidOptions) {
41
+ return null;
42
+ }
43
+ const mediaMode = MEDIA_MODE_BY_PROTO[factoidOptions.mode] ?? 'none';
44
+ const body = factoidOptions.body.slice(0, INSIGHT_BODY_MAX_LENGTH);
45
+ return {
46
+ id: question.id,
47
+ parentId: question.eventId || question.id,
48
+ // `landscapeUrl` ONLY. `portraitUrl` keeps stale artwork from earlier revisions of the
49
+ // card that Studio no longer shows in its Background Image slot, so falling back to it
50
+ // paints a background the author has already removed. Unlike the standard card, an
51
+ // Insight one renders fine with no background at all — its copy is the ad.
52
+ backgroundImageSrc: question.backgroundImage?.landscapeUrl || undefined,
53
+ kind: 'insight',
54
+ layout: 'left',
55
+ isInteractive: false,
56
+ logoSrc: resolveInsightSponsorLogo(question),
57
+ title: question.subject || undefined,
58
+ description: body || undefined,
59
+ mediaSrc: mediaMode === 'none' ? undefined : factoidOptions.image || undefined,
60
+ mediaMode,
61
+ };
62
+ };
63
+ const parsePauseAdCard = (question) => {
64
+ if (question.options?.options.case === 'factoid') {
65
+ return parseInsightPauseAdCard(question);
66
+ }
67
+ const backgroundImageSrc = question.backgroundImage?.landscapeUrl;
68
+ if (!backgroundImageSrc) {
69
+ return null;
70
+ }
71
+ const promotionOptions = question.options?.options.case === 'promotion' ? question.options.options.value : undefined;
72
+ const pauseAdOptions = promotionOptions?.pauseAdOptions;
73
+ const optionsSponsor = promotionOptions?.sponsor;
74
+ const layoutProto = pauseAdOptions?.layout ?? PauseAdLayoutProto.UNSET;
75
+ const layout = layoutProto === PauseAdLayoutProto.CENTER ? 'center' : layoutProto === PauseAdLayoutProto.IMAGE ? 'image' : 'left';
76
+ const isInteractive = promotionOptions?.buttonType === PromotionButtonType.SEND_TO_USER;
77
+ const hasQr = pauseAdOptions?.qrCode === QRCode.QR_CODE_ENABLED && !!pauseAdOptions?.qrCodeUrl;
78
+ let ctaLabel;
79
+ let ctaUrl;
80
+ if (isInteractive && promotionOptions?.buttonOptions.case === 'sendToUserOptions') {
81
+ const accountInfo = promotionOptions.buttonOptions.value.userAccountInfo;
82
+ const isPhone = accountInfo === UserAccountInfo.PHONE;
83
+ ctaUrl = isPhone ? 'sendToPhone:' : 'sendToEmail:';
84
+ ctaLabel = isPhone ? 'Send info to account phone' : 'Send info to account email';
85
+ }
86
+ const logoSrc = optionsSponsor?.logoPosition === NotificationSponsorLogoMode.NOTIFICATION_SPONSOR_LOGO_NONE
87
+ ? undefined
88
+ : optionsSponsor?.useGeneralLogo === UseGeneralLogo.ENABLED
89
+ ? question.promotion?.generalTabMetadata?.advertiserTransparentLogo || undefined
90
+ : optionsSponsor?.centerLogo || undefined;
91
+ return {
92
+ id: question.id,
93
+ parentId: question.eventId || question.id,
94
+ backgroundImageSrc,
95
+ layout,
96
+ logoSrc,
97
+ title: promotionOptions?.banner?.title || undefined,
98
+ description: promotionOptions?.banner?.body || undefined,
99
+ isInteractive,
100
+ ctaLabel: ctaLabel || undefined,
101
+ ctaUrl: ctaUrl || undefined,
102
+ qrCodeUrl: hasQr ? (pauseAdOptions?.qrCodeUrl ?? undefined) : undefined,
103
+ qrCodeLabel: hasQr ? (pauseAdOptions?.qrCodeLabel ?? undefined) : undefined,
104
+ };
105
+ };
106
+ /**
107
+ * Which cards from `pauseAdList` the SDK can actually draw. Both renderers are full bleed; a
108
+ * Sidebar-format pause ad has no chrome yet and is skipped.
109
+ */
110
+ const isFullBleedPauseAd = (question) => {
111
+ if (question.options?.options.case === 'factoid') {
112
+ // Insight Pause Ad (SC-353). Studio does not write `CardFormat` on these cards yet
113
+ // (SLW-7062), so an UNSET format is taken as Full Bleed — the only Insight pause-ad chrome
114
+ // the SDK has. An explicit Sidebar stays out. Drop the UNSET arm once Studio sets the field.
115
+ return question.format === CardFormat.FULL_BLEED || question.format === CardFormat.UNSET;
116
+ }
117
+ const promotionOptions = question.options?.options.case === 'promotion' ? question.options.options.value : undefined;
118
+ return promotionOptions?.format === PromotionFormat.FULL_BLEED || question.format === CardFormat.FULL_BLEED;
119
+ };
120
+ /**
121
+ * The `pauseAdList` response, shaped for the renderers: keep the cards the SDK has chrome for,
122
+ * turn each into a `PauseAdCard`, drop the ones that cannot be drawn.
123
+ *
124
+ * Pure on purpose — `pause-ad.ts` owns the store and the transport, this owns the card shapes,
125
+ * and only this half needs unit tests.
126
+ */
127
+ export const parsePauseAdCards = (questions) => questions
128
+ .filter(isFullBleedPauseAd)
129
+ .map(parsePauseAdCard)
130
+ .filter((card) => card !== null);
@@ -1,21 +1,7 @@
1
1
  import { createSingleStore, StreamLayerContext } from '@streamlayer/sdk-web-interfaces';
2
2
  import '@streamlayer/sdk-web-api';
3
3
  import '@streamlayer/sdk-web-core/store';
4
- export type PauseAdLayout = 'left' | 'center' | 'image';
5
- export type PauseAdCard = {
6
- id: string;
7
- parentId: string;
8
- backgroundImageSrc: string;
9
- layout: PauseAdLayout;
10
- isInteractive: boolean;
11
- logoSrc?: string;
12
- title?: string;
13
- description?: string;
14
- ctaLabel?: string;
15
- ctaUrl?: string;
16
- qrCodeUrl?: string;
17
- qrCodeLabel?: string;
18
- };
4
+ import { type PauseAdCard } from './pause-ad-card';
19
5
  export type PauseAdListOptions = {
20
6
  refetchInterval: number;
21
7
  $enabled: boolean;
@@ -2,49 +2,9 @@ import { createLogger } from '@streamlayer/sdk-web-logger';
2
2
  import { atom } from 'nanostores';
3
3
  import '@streamlayer/sdk-web-api';
4
4
  import '@streamlayer/sdk-web-core/store';
5
- import { CardFormat, NotificationSponsorLogoMode, PauseAdLayout as PauseAdLayoutProto, PromotionButtonType, PromotionFormat, QRCode, UseGeneralLogo, UserAccountInfo, } from '@streamlayer/sl-eslib/interactive/interactive.common_pb';
6
5
  import { Feed } from '@streamlayer/sl-eslib/interactive/feed/interactive.feed_pb';
6
+ import { parsePauseAdCards } from './pause-ad-card';
7
7
  const log = createLogger('advertisement:pause-ad');
8
- const parsePauseAdCard = (question) => {
9
- const backgroundImageSrc = question.backgroundImage?.landscapeUrl;
10
- if (!backgroundImageSrc) {
11
- return null;
12
- }
13
- const promotionOptions = question.options?.options.case === 'promotion' ? question.options.options.value : undefined;
14
- const pauseAdOptions = promotionOptions?.pauseAdOptions;
15
- const optionsSponsor = promotionOptions?.sponsor;
16
- const layoutProto = pauseAdOptions?.layout ?? PauseAdLayoutProto.UNSET;
17
- const layout = layoutProto === PauseAdLayoutProto.CENTER ? 'center' : layoutProto === PauseAdLayoutProto.IMAGE ? 'image' : 'left';
18
- const isInteractive = promotionOptions?.buttonType === PromotionButtonType.SEND_TO_USER;
19
- const hasQr = pauseAdOptions?.qrCode === QRCode.QR_CODE_ENABLED && !!pauseAdOptions?.qrCodeUrl;
20
- let ctaLabel;
21
- let ctaUrl;
22
- if (isInteractive && promotionOptions?.buttonOptions.case === 'sendToUserOptions') {
23
- const accountInfo = promotionOptions.buttonOptions.value.userAccountInfo;
24
- const isPhone = accountInfo === UserAccountInfo.PHONE;
25
- ctaUrl = isPhone ? 'sendToPhone:' : 'sendToEmail:';
26
- ctaLabel = isPhone ? 'Send info to account phone' : 'Send info to account email';
27
- }
28
- const logoSrc = optionsSponsor?.logoPosition === NotificationSponsorLogoMode.NOTIFICATION_SPONSOR_LOGO_NONE
29
- ? undefined
30
- : optionsSponsor?.useGeneralLogo === UseGeneralLogo.ENABLED
31
- ? question.promotion?.generalTabMetadata?.advertiserTransparentLogo || undefined
32
- : optionsSponsor?.centerLogo || undefined;
33
- return {
34
- id: question.id,
35
- parentId: question.eventId || question.id,
36
- backgroundImageSrc,
37
- layout,
38
- logoSrc,
39
- title: promotionOptions?.banner?.title || undefined,
40
- description: promotionOptions?.banner?.body || undefined,
41
- isInteractive,
42
- ctaLabel: ctaLabel || undefined,
43
- ctaUrl: ctaUrl || undefined,
44
- qrCodeUrl: hasQr ? (pauseAdOptions?.qrCodeUrl ?? undefined) : undefined,
45
- qrCodeLabel: hasQr ? (pauseAdOptions?.qrCodeLabel ?? undefined) : undefined,
46
- };
47
- };
48
8
  export const pauseAdListStore = (instance) => {
49
9
  const $slStreamId = instance.stores.slStreamId.getAtomStore();
50
10
  return ($enabled, options) => {
@@ -61,14 +21,7 @@ export const pauseAdListStore = (instance) => {
61
21
  }
62
22
  log.debug({ eventId }, 'fetching pause ad list');
63
23
  const res = await client.pauseAdList({ eventId });
64
- const questions = res.data ?? [];
65
- const cards = questions
66
- .filter((q) => {
67
- const opts = q.options?.options.case === 'promotion' ? q.options.options.value : undefined;
68
- return opts?.format === PromotionFormat.FULL_BLEED || q.format === CardFormat.FULL_BLEED;
69
- })
70
- .map((q) => parsePauseAdCard(q))
71
- .filter((c) => c !== null);
24
+ const cards = parsePauseAdCards(res.data ?? []);
72
25
  log.debug({ count: cards.length }, 'parsed %d fullbleed pause ad cards');
73
26
  return cards;
74
27
  },
@@ -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
+ }