@roku-sdk/ads 0.0.0-stage → 2.19.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/LICENSE +21 -0
- package/README.md +20 -2
- package/package.json +47 -3
- package/src/__tests__/ads.test.tsx +735 -0
- package/src/ads.tsx +388 -0
- package/src/index.ts +22 -0
- package/src/xml-components/ads/ads.brs +198 -0
- package/src/xml-components/ads/ads.xml +35 -0
- package/src/xml-components/ads/ads.xml.d.ts +68 -0
- package/tsconfig.json +9 -0
package/src/ads.tsx
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { IContentNodeProps } from "@roku-sdk/components";
|
|
5
|
+
import { DomNode } from "@roku-sdk/interfaces";
|
|
6
|
+
import type { Component } from "@roku-sdk/solid";
|
|
7
|
+
|
|
8
|
+
import AdsXML from "./xml-components/ads/ads.xml";
|
|
9
|
+
void AdsXML; // Ensure the import is preserved
|
|
10
|
+
|
|
11
|
+
export type EventHandler<T> = (event: T) => void;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* States the RAF task reports. There is no failure state: if RAF is not linked the
|
|
15
|
+
* task cannot run far enough to report anything, so "never reached `"ready"`" is
|
|
16
|
+
* what an initialisation failure looks like.
|
|
17
|
+
*/
|
|
18
|
+
export type AdsState = "none" | "ready" | "stopped";
|
|
19
|
+
export type EventHandlerAdsState = EventHandler<AdsState>;
|
|
20
|
+
export type ShowAdsResult = "none" | "completed" | "user_exited" | "error";
|
|
21
|
+
export type ConstructStitchedStreamResult = "none" | "completed" | "error";
|
|
22
|
+
export type RenderStitchedStreamResult = "none" | "completed" | "user_exited" | "error";
|
|
23
|
+
export type StitchedAdsInitResult = "none" | "completed" | "error";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Ad counter shown during a CSAI break: `{ start: 1, total: 4 }` renders "Ad 1 of 4".
|
|
27
|
+
*
|
|
28
|
+
* A type alias rather than an interface so it stays assignable to the SceneGraph
|
|
29
|
+
* assocarray field the task reads it from.
|
|
30
|
+
*/
|
|
31
|
+
export type AdCounter = {
|
|
32
|
+
/** Position of the first ad in this call, 1-based. */
|
|
33
|
+
start: number;
|
|
34
|
+
/** Total ads the counter should count towards, across the whole break. */
|
|
35
|
+
total: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type AdsCommand =
|
|
39
|
+
"none" | "getAds" | "showAds" | "stop" | "stitchedAdsInit" | "constructStitchedStream" | "renderStitchedStream";
|
|
40
|
+
|
|
41
|
+
/*
|
|
42
|
+
* The five interfaces below describe the payload RAF hands back from `getAds()`,
|
|
43
|
+
* as observed crossing the SceneGraph bridge.
|
|
44
|
+
*
|
|
45
|
+
* Two things shape them. Field names are RAF's own and are **lowercase** over the
|
|
46
|
+
* bridge (`rendersequence`, `mimetype`) rather than camelCase. And RAF passes
|
|
47
|
+
* ad-server data through largely untouched, so which fields a given response
|
|
48
|
+
* carries depends on the VAST tag that answered: everything a pod is not
|
|
49
|
+
* structurally obliged to have is therefore optional, and a response may well
|
|
50
|
+
* carry fields not listed here.
|
|
51
|
+
*
|
|
52
|
+
* The one structural assumption is `AdPod.ads` — the task normalises RAF's reply
|
|
53
|
+
* to the pod array before writing it, and a pod with no ads has nothing to play.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A single media stream for an ad creative. Several streams describe the same
|
|
58
|
+
* creative at different bitrates or in different formats.
|
|
59
|
+
*/
|
|
60
|
+
export interface AdStream {
|
|
61
|
+
/** Stream URL. */
|
|
62
|
+
url?: string;
|
|
63
|
+
/** Stream bitrate in kbps. */
|
|
64
|
+
bitrate?: number;
|
|
65
|
+
/** Video width in pixels. */
|
|
66
|
+
width?: number;
|
|
67
|
+
/** Video height in pixels. */
|
|
68
|
+
height?: number;
|
|
69
|
+
/** MIME type, e.g. `"video/mp4"`. */
|
|
70
|
+
mimetype?: string;
|
|
71
|
+
/** Stream provider. */
|
|
72
|
+
provider?: string;
|
|
73
|
+
/** Stream identifier assigned by the ad server, e.g. `"GDFP"`. */
|
|
74
|
+
id?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One tracking event RAF fires for an ad or pod.
|
|
79
|
+
*
|
|
80
|
+
* Note the shape: tracking arrives as a flat list of events, not as a map keyed
|
|
81
|
+
* by event name, so finding one means searching on {@link AdTrackingEvent.event}.
|
|
82
|
+
*/
|
|
83
|
+
export interface AdTrackingEvent {
|
|
84
|
+
/** Event name, e.g. `"Impression"`, `"Start"`, `"Error"`. */
|
|
85
|
+
event?: string;
|
|
86
|
+
/** URL fired for the event. */
|
|
87
|
+
url?: string;
|
|
88
|
+
/** Ad this event belongs to. */
|
|
89
|
+
adid?: string;
|
|
90
|
+
/** Creative this event belongs to. */
|
|
91
|
+
creativeid?: string;
|
|
92
|
+
/** Whether RAF has already fired it. */
|
|
93
|
+
triggered?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A companion banner offered alongside an ad. */
|
|
97
|
+
export interface CompanionAd {
|
|
98
|
+
/** Banner asset URL. */
|
|
99
|
+
url?: string;
|
|
100
|
+
/** Banner width in pixels. */
|
|
101
|
+
width?: number;
|
|
102
|
+
/** Banner height in pixels. */
|
|
103
|
+
height?: number;
|
|
104
|
+
/** MIME type of the banner asset. */
|
|
105
|
+
mimetype?: string;
|
|
106
|
+
/** Banner provider. */
|
|
107
|
+
provider?: string;
|
|
108
|
+
/** Click-through URL. */
|
|
109
|
+
clickthrough?: string;
|
|
110
|
+
/** Tracking events for the banner. */
|
|
111
|
+
tracking?: AdTrackingEvent[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A single ad within an ad pod. */
|
|
115
|
+
export interface Ad {
|
|
116
|
+
/** Ad duration in seconds. */
|
|
117
|
+
duration?: number;
|
|
118
|
+
/** Ad title from VAST. */
|
|
119
|
+
adtitle?: string;
|
|
120
|
+
/** Creative streams, typically the same creative at several bitrates. */
|
|
121
|
+
streams?: AdStream[];
|
|
122
|
+
/** Tracking events, as a flat list rather than keyed by event name. */
|
|
123
|
+
tracking?: AdTrackingEvent[];
|
|
124
|
+
/** Companion banners offered with this ad. */
|
|
125
|
+
companionads?: CompanionAd[];
|
|
126
|
+
/** Click-through URL. */
|
|
127
|
+
clickthrough?: string;
|
|
128
|
+
/** Ad identifier. */
|
|
129
|
+
adid?: string;
|
|
130
|
+
/** Creative identifier. */
|
|
131
|
+
creativeid?: string;
|
|
132
|
+
/** Creative ad identifier from VAST. */
|
|
133
|
+
creativeadid?: string;
|
|
134
|
+
/** Ad server the creative came from. */
|
|
135
|
+
adserver?: string;
|
|
136
|
+
/** Ad system named in the VAST response. */
|
|
137
|
+
adsystem?: string;
|
|
138
|
+
/** Identifier for the ad request that produced this ad. */
|
|
139
|
+
requestid?: string;
|
|
140
|
+
/** Stream format reported for the creative. */
|
|
141
|
+
streamformat?: string;
|
|
142
|
+
/** VAST `AdParameters`, passed through as the opaque string the tag carried. */
|
|
143
|
+
adparameters?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A pod (group) of one or more ads. Pods correspond to ad breaks.
|
|
148
|
+
*
|
|
149
|
+
* There is no ad count: it is `ads.length`. The break's slot is
|
|
150
|
+
* {@link AdPod.rendersequence}, a string, and its scheduled offset is
|
|
151
|
+
* {@link AdPod.rendertime} — which RAF acts on itself, so neither is a cue the
|
|
152
|
+
* app has to schedule against.
|
|
153
|
+
*/
|
|
154
|
+
export interface AdPod {
|
|
155
|
+
/** Ads in this pod, in playback order. */
|
|
156
|
+
ads: Ad[];
|
|
157
|
+
/** Duration of the entire pod in seconds. */
|
|
158
|
+
duration?: number;
|
|
159
|
+
/** Where the break sits: `"preroll"`, `"midroll"` or `"postroll"`. */
|
|
160
|
+
rendersequence?: string;
|
|
161
|
+
/**
|
|
162
|
+
* Playback position in seconds the break is scheduled at. RAF schedules the
|
|
163
|
+
* break itself, so this describes the pod rather than being something the app
|
|
164
|
+
* has to act on.
|
|
165
|
+
*/
|
|
166
|
+
rendertime?: number;
|
|
167
|
+
/** Tracking events for the pod, as a flat list. */
|
|
168
|
+
tracking?: AdTrackingEvent[];
|
|
169
|
+
/** Whether the pod has already been viewed. */
|
|
170
|
+
viewed?: boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Handler for the ad pods a `getAds` command returns.
|
|
175
|
+
*
|
|
176
|
+
* The underlying SceneGraph field is an untyped array, so {@link AdPod} is a
|
|
177
|
+
* narrowing of it rather than a guarantee the bridge enforces. It matches what
|
|
178
|
+
* RAF was observed to send, and its fields are optional accordingly — but a pod
|
|
179
|
+
* field is worth reading only if the app genuinely needs it. Deciding whether a
|
|
180
|
+
* break exists takes just `pods.length`.
|
|
181
|
+
*/
|
|
182
|
+
export type EventHandlerAdPods = EventHandler<AdPod[]>;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Props for the {@link Ads} SceneGraph Task wrapper.
|
|
186
|
+
*
|
|
187
|
+
* The underlying `ads` component runs RAF (`Roku_Ads()`) in a background BrightScript
|
|
188
|
+
* task. TypeScript drives ad fetch and render by writing reactive props; the task
|
|
189
|
+
* invokes callbacks when results are ready.
|
|
190
|
+
*
|
|
191
|
+
* Typical CSAI flow:
|
|
192
|
+
* 1. Wait for {@link IAdsProps.onAdsState} with `"ready"`
|
|
193
|
+
* 2. Set {@link IAdsProps.adUrl} and {@link IAdsProps.command} to `"getAds"`
|
|
194
|
+
* 3. Handle {@link IAdsProps.onGetAdsResult}; if pods are returned, set {@link IAdsProps.command} to `"showAds"`
|
|
195
|
+
* 4. Handle {@link IAdsProps.onShowAdsResult} and resume content playback
|
|
196
|
+
* 5. When the component is unmounted, set {@link IAdsProps.command} to `"stop"` to stop the RAF task.
|
|
197
|
+
*/
|
|
198
|
+
export interface IAdsProps {
|
|
199
|
+
/** When `true`, enables verbose RAF debug logging on the device. */
|
|
200
|
+
debugOutput?: boolean;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Command dispatched to the RAF task. Write it **last**: the task reads the other
|
|
204
|
+
* fields at the moment the command arrives, so a data field set after it is not
|
|
205
|
+
* seen by the command it was meant for.
|
|
206
|
+
*
|
|
207
|
+
* Common to every mode:
|
|
208
|
+
* - `"none"` — matches no branch in the task, so it does nothing. That is what
|
|
209
|
+
* makes it usable as the reset value described below.
|
|
210
|
+
* - `"getAds"` — fetch pods for the current {@link IAdsProps.adUrl}; answered by
|
|
211
|
+
* {@link IAdsProps.onGetAdsResult}. An empty or unset `adUrl` is answered with
|
|
212
|
+
* an empty array without a network request.
|
|
213
|
+
* - `"stop"` — leave the task's command loop, driving {@link IAdsProps.onAdsState}
|
|
214
|
+
* to `"stopped"`. No command after it is observed.
|
|
215
|
+
*
|
|
216
|
+
* CSAI — client-side insertion:
|
|
217
|
+
* - `"showAds"` — render the pods from the most recent `getAds` into
|
|
218
|
+
* {@link IAdsProps.adView}; answered by {@link IAdsProps.onShowAdsResult}. A
|
|
219
|
+
* missing `adView` is reported as `"error"`.
|
|
220
|
+
*
|
|
221
|
+
* SSAI — server-stitched insertion:
|
|
222
|
+
* - `"stitchedAdsInit"` — hand the raw `getAds` result to RAF's stitched playback
|
|
223
|
+
* and switch the task to SSAI mode; answered by
|
|
224
|
+
* {@link IAdsProps.onStitchedAdsInitResult}. Empty pods are reported as
|
|
225
|
+
* `"error"` and leave the mode alone. Stitched ads met later surface through
|
|
226
|
+
* {@link IAdsProps.onStitchedAdResult}, which content position drives rather
|
|
227
|
+
* than this command.
|
|
228
|
+
*
|
|
229
|
+
* CSAS — client-side stitching:
|
|
230
|
+
* - `"constructStitchedStream"` — build a stream from
|
|
231
|
+
* {@link IAdsProps.contentMetadata}; answered by
|
|
232
|
+
* {@link IAdsProps.onConstructStitchedStreamResult}. On failure the task reverts
|
|
233
|
+
* to CSAI mode, reported only as `"error"`.
|
|
234
|
+
* - `"renderStitchedStream"` — play that stream into {@link IAdsProps.adView};
|
|
235
|
+
* answered by {@link IAdsProps.onRenderStitchedStreamResult}.
|
|
236
|
+
*
|
|
237
|
+
* **Compared by value.** Writing a value equal to the current one writes nothing,
|
|
238
|
+
* so the same command twice in a row silently does nothing the second time. Reset
|
|
239
|
+
* through `"none"` between two identical commands.
|
|
240
|
+
*/
|
|
241
|
+
command?: AdsCommand;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Fired when the RAF task state changes (notably `"ready"` after init).
|
|
245
|
+
*
|
|
246
|
+
* A failure to initialise arrives as `"ready"` never being reported, not as a
|
|
247
|
+
* state of its own — there is nothing to branch on, so treat a missing
|
|
248
|
+
* `"ready"` as the error.
|
|
249
|
+
*/
|
|
250
|
+
onAdsState?: EventHandlerAdsState;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* VMAP/VAST ad server URL passed to `Roku_Ads().setAdUrl()` before each `getAds`.
|
|
254
|
+
*
|
|
255
|
+
* **Note:** Mid-roll cue points observed by the task reuse the last `adUrl` set via
|
|
256
|
+
* the `"getAds"` command. Per-break URL updates are not currently supported.
|
|
257
|
+
*/
|
|
258
|
+
adUrl?: string;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Fired when a `getAds` command completes. Receives an array of ad pods.
|
|
262
|
+
*
|
|
263
|
+
* Unlike the string result handlers, this fires once per command with no `"none"`
|
|
264
|
+
* sentinel: the field is an array, so the observer fires on the write itself.
|
|
265
|
+
*/
|
|
266
|
+
onGetAdsResult?: EventHandlerAdPods;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* SceneGraph node RAF renders ads into during `showAds`.
|
|
270
|
+
* For non-stitched CSAI, any renderable node whose lifetime spans the ad break is valid.
|
|
271
|
+
*/
|
|
272
|
+
adView?: DomNode;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Content playback node (typically a `Video`) whose `position` field the RAF task observes
|
|
276
|
+
* to trigger mid-roll `getAds` calls automatically.
|
|
277
|
+
*
|
|
278
|
+
* **Note:** The task binds to the first non-invalid `contentView` and does not re-observe
|
|
279
|
+
* on subsequent changes. If the Video node is replaced (e.g., content navigation), the
|
|
280
|
+
* `<Ads>` component must be unmounted and remounted to rebind mid-roll observation.
|
|
281
|
+
*/
|
|
282
|
+
contentView?: DomNode;
|
|
283
|
+
|
|
284
|
+
/** Content metadata used in CSAS mode to construct the stitched stream. `Roku_Ads().constructStitchedStream()`. */
|
|
285
|
+
contentMetadata?: IContentNodeProps | Record<string, unknown>;
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Optional `{ start, total }` ad counter RAF draws during a `showAds` break, so
|
|
289
|
+
* `{ start: 1, total: 4 }` reads "Ad 1 of 4". Use it when the app renders part of
|
|
290
|
+
* a break itself and RAF's own count would be wrong. Omitted, RAF counts the pod
|
|
291
|
+
* it was given.
|
|
292
|
+
*/
|
|
293
|
+
adCounter?: AdCounter;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Fired when a `showAds` command completes. Receives `"completed"` when the break
|
|
297
|
+
* played through, `"user_exited"` when the viewer left it early (typically Back),
|
|
298
|
+
* or `"error"` when {@link IAdsProps.adView} is missing.
|
|
299
|
+
*
|
|
300
|
+
* `"user_exited"` is not a failure, and the two usually call for different
|
|
301
|
+
* handling: content resumes after `"completed"`, while leaving a break early
|
|
302
|
+
* generally means leaving the content too.
|
|
303
|
+
*
|
|
304
|
+
* **Fires with `"none"` first.** The task clears the field to that sentinel before
|
|
305
|
+
* running the break, so the real value arrives in a later call. Treat `"none"` as
|
|
306
|
+
* "no result yet" rather than as a failure.
|
|
307
|
+
*/
|
|
308
|
+
onShowAdsResult?: EventHandler<ShowAdsResult>;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Fired when a `stitchedAdsInit` command completes. Receives `"completed"` once
|
|
312
|
+
* non-empty pods have been handed to RAF and the task has switched to SSAI mode,
|
|
313
|
+
* or `"error"` when there were no pods to hand over.
|
|
314
|
+
*
|
|
315
|
+
* RAF reports nothing back from this call, so `"completed"` says the pods were
|
|
316
|
+
* passed on, not that RAF accepted them.
|
|
317
|
+
*
|
|
318
|
+
* **Fires with `"none"` first**, as {@link IAdsProps.onShowAdsResult} does.
|
|
319
|
+
*/
|
|
320
|
+
onStitchedAdsInitResult?: EventHandler<StitchedAdsInitResult>;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Fired for each stitched ad the task handles during SSAI playback, with the context
|
|
324
|
+
* RAF reports for it.
|
|
325
|
+
*
|
|
326
|
+
* Driven by `contentView` position, **not** by the `stitchedAdsInit` command —
|
|
327
|
+
* {@link IAdsProps.onStitchedAdsInitResult} is that command's acknowledgement.
|
|
328
|
+
*/
|
|
329
|
+
onStitchedAdResult?: EventHandler<Record<string, unknown>>;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Fired when a `constructStitchedStream` command completes. Receives `"completed"`
|
|
333
|
+
* once the stream is built, or `"error"` when {@link IAdsProps.contentMetadata} is
|
|
334
|
+
* missing or RAF could not build it — in which case the task reverts to CSAI mode.
|
|
335
|
+
*
|
|
336
|
+
* **Fires with `"none"` first**, as {@link IAdsProps.onShowAdsResult} does.
|
|
337
|
+
*/
|
|
338
|
+
onConstructStitchedStreamResult?: EventHandler<ConstructStitchedStreamResult>;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Fired when a `renderStitchedStream` command completes. Receives `"completed"` when
|
|
342
|
+
* the stream played through, `"user_exited"` when playback ended before that, or
|
|
343
|
+
* `"error"` when no stream has been constructed or {@link IAdsProps.adView} is missing.
|
|
344
|
+
*
|
|
345
|
+
* **Fires with `"none"` first**, as {@link IAdsProps.onShowAdsResult} does.
|
|
346
|
+
*/
|
|
347
|
+
onRenderStitchedStreamResult?: EventHandler<RenderStitchedStreamResult>;
|
|
348
|
+
|
|
349
|
+
/** Enable audience measurement. Set before issuing `command = "getAds"` so RAF has measurement context when ad pods are fetched. */
|
|
350
|
+
enableAdMeasurements?: boolean;
|
|
351
|
+
|
|
352
|
+
/** Content genre for audience measurement. Set before `command = "getAds"`. */
|
|
353
|
+
contentGenre?: string;
|
|
354
|
+
|
|
355
|
+
/** Content ID for audience measurement. Set before `command = "getAds"`. */
|
|
356
|
+
contentId?: string;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Headless SceneGraph Task that bridges TypeScript to Roku Advertising Framework (RAF).
|
|
361
|
+
*
|
|
362
|
+
* Renders no visible UI — mount it in the component tree and drive ad operations via
|
|
363
|
+
* {@link IAdsProps.command} and the callback props on {@link IAdsProps}.
|
|
364
|
+
*
|
|
365
|
+
* @example
|
|
366
|
+
* ```tsx
|
|
367
|
+
* <Ads
|
|
368
|
+
* debugOutput={true}
|
|
369
|
+
* adUrl={vmapUrl}
|
|
370
|
+
* adView={adViewNode}
|
|
371
|
+
* contentView={videoNode}
|
|
372
|
+
* command={adCommand()}
|
|
373
|
+
* onAdsState={(state) => state === "ready" && setAdCommand("getAds")}
|
|
374
|
+
* onGetAdsResult={(pods) => pods.length && setAdCommand("showAds")}
|
|
375
|
+
* onShowAdsResult={(result) => result === "completed" && setContentControl("play")}
|
|
376
|
+
* />
|
|
377
|
+
* ```
|
|
378
|
+
*/
|
|
379
|
+
export const Ads: Component<IAdsProps> = props => {
|
|
380
|
+
// `contentMetadata` is widened in the public API to accept a typed
|
|
381
|
+
// `IContentNodeProps`, but the native <ads> field is a SceneGraph assocarray
|
|
382
|
+
// (`Record<string, unknown>`) and TS interfaces have no index signature. The
|
|
383
|
+
// shapes are structurally compatible at runtime, so coerce just this field at
|
|
384
|
+
// the boundary; the spread stays reactive since the proxy is unchanged.
|
|
385
|
+
return (
|
|
386
|
+
<ads {...(props as Omit<IAdsProps, "contentMetadata"> & { contentMetadata?: Record<string, unknown> })}></ads>
|
|
387
|
+
);
|
|
388
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
export { Ads } from "./ads";
|
|
5
|
+
export type {
|
|
6
|
+
AdsState,
|
|
7
|
+
EventHandlerAdsState,
|
|
8
|
+
AdsCommand,
|
|
9
|
+
EventHandlerAdPods,
|
|
10
|
+
EventHandler,
|
|
11
|
+
IAdsProps,
|
|
12
|
+
Ad,
|
|
13
|
+
AdCounter,
|
|
14
|
+
AdPod,
|
|
15
|
+
AdStream,
|
|
16
|
+
AdTrackingEvent,
|
|
17
|
+
CompanionAd,
|
|
18
|
+
ShowAdsResult,
|
|
19
|
+
StitchedAdsInitResult,
|
|
20
|
+
ConstructStitchedStreamResult,
|
|
21
|
+
RenderStitchedStreamResult
|
|
22
|
+
} from "./ads";
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
' Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
' SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
' RAF bridge Task for the @roku-sdk/ads <Ads> component.
|
|
5
|
+
'
|
|
6
|
+
' TypeScript drives ad fetch/render by writing SG interface fields on this Task node.
|
|
7
|
+
' Results flow back through getAdsResult / showAdsResult / adsState, which the TS
|
|
8
|
+
' wrapper exposes as reactive callbacks.
|
|
9
|
+
'
|
|
10
|
+
' CSAI flow (pre-roll):
|
|
11
|
+
' adsState = "ready" → command = "getAds" → command = "showAds" → resume content
|
|
12
|
+
'
|
|
13
|
+
' Mid-roll: once contentView is set, this task observes Video.position and calls
|
|
14
|
+
' getAds(msg) automatically at cue points. The app must still issue showAds.
|
|
15
|
+
|
|
16
|
+
Library "Roku_Ads.brs"
|
|
17
|
+
|
|
18
|
+
Sub init()
|
|
19
|
+
m.top.getAdsResult = []
|
|
20
|
+
m.top.functionName = "adsEventLoop"
|
|
21
|
+
m.top.control = "run"
|
|
22
|
+
End Sub
|
|
23
|
+
|
|
24
|
+
sub adsEventLoop()
|
|
25
|
+
raf = Roku_Ads()
|
|
26
|
+
' Pods from the most recent getAds; passed to showAds until the next getAds.
|
|
27
|
+
ads = []
|
|
28
|
+
contentView = invalid
|
|
29
|
+
contentMetadata = invalid
|
|
30
|
+
mode = "csai" ' default to CSAI mode
|
|
31
|
+
csasStream = invalid
|
|
32
|
+
' Set while pods written to getAdsResult are unacted on; holds off the cue-point fetch.
|
|
33
|
+
podsPending = false
|
|
34
|
+
|
|
35
|
+
msgPort = CreateObject("roMessagePort")
|
|
36
|
+
m.top.observeField("command", msgPort)
|
|
37
|
+
m.top.observeField("debugOutput", msgPort)
|
|
38
|
+
m.top.observeField("contentView", msgPort)
|
|
39
|
+
m.top.observeField("contentMetadata", msgPort)
|
|
40
|
+
m.top.observeField("enableAdMeasurements", msgPort)
|
|
41
|
+
m.top.observeField("contentGenre", msgPort)
|
|
42
|
+
m.top.observeField("contentId", msgPort)
|
|
43
|
+
|
|
44
|
+
' Signals TypeScript (onAdsState) that RAF is initialised and commands can be sent.
|
|
45
|
+
m.top.adsState = "ready"
|
|
46
|
+
|
|
47
|
+
while true
|
|
48
|
+
msg = wait(0, msgPort)
|
|
49
|
+
if msg = invalid then continue while
|
|
50
|
+
field = msg.getField()
|
|
51
|
+
if field = "command"
|
|
52
|
+
cmd = msg.getData()
|
|
53
|
+
' Any command clears the hold: the app has acted on the pods it was handed.
|
|
54
|
+
podsPending = false
|
|
55
|
+
if cmd = "getAds"
|
|
56
|
+
if m.top.adUrl = "" or m.top.adUrl = invalid
|
|
57
|
+
' Drop the held pods too, or a later showAds renders what the app was told it has none of.
|
|
58
|
+
ads = []
|
|
59
|
+
updateGetAdsResult([])
|
|
60
|
+
else
|
|
61
|
+
raf.setAdUrl(m.top.adUrl)
|
|
62
|
+
ads = raf.getAds()
|
|
63
|
+
podsPending = updateGetAdsResult(ads) > 0
|
|
64
|
+
end if
|
|
65
|
+
else if cmd = "showAds"
|
|
66
|
+
' Clear before both outcomes, not just the render: writing the same
|
|
67
|
+
' value the field already holds notifies nobody.
|
|
68
|
+
m.top.showAdsResult = "none"
|
|
69
|
+
if m.top.adView = invalid
|
|
70
|
+
m.top.showAdsResult = "error"
|
|
71
|
+
else
|
|
72
|
+
' CSAI: ads render into adView (3rd arg), ad counter into ctx (2nd).
|
|
73
|
+
' RAF returns false when the viewer left the break early, not on error.
|
|
74
|
+
if raf.showAds(ads, adCounterCtx(m.top.adCounter), m.top.adView)
|
|
75
|
+
m.top.showAdsResult = "completed"
|
|
76
|
+
else
|
|
77
|
+
m.top.showAdsResult = "user_exited"
|
|
78
|
+
end if
|
|
79
|
+
end if
|
|
80
|
+
else if cmd = "constructStitchedStream"
|
|
81
|
+
m.top.constructStitchedStreamResult = "none"
|
|
82
|
+
if contentMetadata = invalid
|
|
83
|
+
m.top.constructStitchedStreamResult = "error"
|
|
84
|
+
else
|
|
85
|
+
csasStream = raf.constructStitchedStream(contentMetadata, ads)
|
|
86
|
+
' Re-observe contentMetadata so a subsequent CSAS session can update it.
|
|
87
|
+
m.top.observeField("contentMetadata", msgPort)
|
|
88
|
+
if csasStream <> invalid
|
|
89
|
+
mode = "csas"
|
|
90
|
+
m.top.constructStitchedStreamResult = "completed"
|
|
91
|
+
else
|
|
92
|
+
mode = "csai" ' fall back to CSAI mode if constructStitchedStream fails
|
|
93
|
+
m.top.constructStitchedStreamResult = "error"
|
|
94
|
+
end if
|
|
95
|
+
end if
|
|
96
|
+
else if cmd = "renderStitchedStream"
|
|
97
|
+
m.top.renderStitchedStreamResult = "none"
|
|
98
|
+
if csasStream = invalid
|
|
99
|
+
m.top.renderStitchedStreamResult = "error"
|
|
100
|
+
else if m.top.adView = invalid
|
|
101
|
+
m.top.renderStitchedStreamResult = "error"
|
|
102
|
+
else if raf.renderStitchedStream(csasStream, m.top.adView)
|
|
103
|
+
m.top.renderStitchedStreamResult = "completed"
|
|
104
|
+
else
|
|
105
|
+
m.top.renderStitchedStreamResult = "user_exited"
|
|
106
|
+
end if
|
|
107
|
+
else if cmd = "stitchedAdsInit"
|
|
108
|
+
m.top.stitchedAdsInitResult = "none"
|
|
109
|
+
' Pass raw RAF result (not normalized array) — stitchedAdsInit
|
|
110
|
+
' needs the full assocarray returned by getAds(), not just the
|
|
111
|
+
' extracted pod array that updateGetAdsResult writes to the field.
|
|
112
|
+
adPods = ads
|
|
113
|
+
if type(ads) = "roAssociativeArray" then adPods = ads.ads
|
|
114
|
+
if adPods = invalid or adPods.count() = 0
|
|
115
|
+
' Nothing to initialise: staying in CSAI would leave position
|
|
116
|
+
' events offering mid-roll pods for a stream never set up for them.
|
|
117
|
+
m.top.stitchedAdsInitResult = "error"
|
|
118
|
+
else
|
|
119
|
+
raf.stitchedAdsInit(ads) ' pass raw result, not extracted pods
|
|
120
|
+
mode = "ssai" ' switch to SSAI mode
|
|
121
|
+
' RAF returns nothing here: "completed" means pods were handed over, not accepted.
|
|
122
|
+
m.top.stitchedAdsInitResult = "completed"
|
|
123
|
+
end if
|
|
124
|
+
else if cmd = "stop"
|
|
125
|
+
exit while
|
|
126
|
+
end if
|
|
127
|
+
else if field = "contentView"
|
|
128
|
+
contentView = m.top.contentView
|
|
129
|
+
if contentView <> invalid
|
|
130
|
+
' One-shot hook: mid-roll getAds is driven by position changes on contentView.
|
|
131
|
+
contentView.observeField("position", msgPort)
|
|
132
|
+
m.top.unobserveField("contentView")
|
|
133
|
+
end if
|
|
134
|
+
else if field = "contentMetadata"
|
|
135
|
+
metadata = m.top.contentMetadata
|
|
136
|
+
if metadata <> invalid
|
|
137
|
+
contentMetadata = CreateObject("roSGNode", "ContentNode")
|
|
138
|
+
contentMetadata.Update(metadata, true)
|
|
139
|
+
m.top.unobserveField("contentMetadata")
|
|
140
|
+
end if
|
|
141
|
+
else if field = "position"
|
|
142
|
+
if mode = "csai" and podsPending
|
|
143
|
+
' Skip one fetch so the outstanding pods survive, and clear the hold
|
|
144
|
+
' unconditionally so an app that issues no command cannot stall mid-roll.
|
|
145
|
+
podsPending = false
|
|
146
|
+
else if mode = "csai"
|
|
147
|
+
' Only notify TS when RAF returns actual pods at a cue point.
|
|
148
|
+
' NOTE: mid-roll reuses the last adUrl set via the "getAds" command.
|
|
149
|
+
' Per-break URL updates are not currently supported.
|
|
150
|
+
midRollAds = raf.getAds(msg)
|
|
151
|
+
if midRollAds <> invalid and midRollAds.count() > 0
|
|
152
|
+
ads = midRollAds
|
|
153
|
+
podsPending = updateGetAdsResult(ads) > 0
|
|
154
|
+
end if
|
|
155
|
+
else if mode = "ssai"
|
|
156
|
+
currentAdContext = raf.stitchedAdHandledEvent(msg, contentView)
|
|
157
|
+
if currentAdContext <> invalid
|
|
158
|
+
m.top.stitchedAdResult = currentAdContext
|
|
159
|
+
end if
|
|
160
|
+
end if
|
|
161
|
+
else if field = "debugOutput"
|
|
162
|
+
raf.setDebugOutput(msg.getData())
|
|
163
|
+
else if field = "enableAdMeasurements"
|
|
164
|
+
raf.enableAdMeasurements(msg.getData() = true)
|
|
165
|
+
else if field = "contentGenre"
|
|
166
|
+
genre = msg.getData()
|
|
167
|
+
if genre <> "" then raf.setContentGenre(genre)
|
|
168
|
+
else if field = "contentId"
|
|
169
|
+
cid = msg.getData()
|
|
170
|
+
if cid <> "" then raf.setContentId(cid)
|
|
171
|
+
end if
|
|
172
|
+
end while
|
|
173
|
+
m.top.adsState = "stopped"
|
|
174
|
+
End Sub
|
|
175
|
+
|
|
176
|
+
' Writes pods to getAdsResult and returns how many were written. RAF may return a
|
|
177
|
+
' bare array or an assocarray with an "ads" key.
|
|
178
|
+
' No reset-before-write here: RAF returns a new array reference each call, so the
|
|
179
|
+
' SG observer fires without needing a sentinel clear (unlike showAdsResult which is a string).
|
|
180
|
+
function updateGetAdsResult(ads) as Integer
|
|
181
|
+
if ads = invalid then ads = []
|
|
182
|
+
if type(ads) = "roArray"
|
|
183
|
+
m.top.getAdsResult = ads
|
|
184
|
+
else
|
|
185
|
+
result = ads.ads
|
|
186
|
+
if result = invalid then result = []
|
|
187
|
+
m.top.getAdsResult = result
|
|
188
|
+
end if
|
|
189
|
+
return m.top.getAdsResult.count()
|
|
190
|
+
end function
|
|
191
|
+
|
|
192
|
+
' RAF's showAds ctx argument: { start, total } renders an "Ad 1 of 4" counter.
|
|
193
|
+
' An unset SG assocarray field arrives as an empty one, which RAF must not be given.
|
|
194
|
+
function adCounterCtx(counter) as Object
|
|
195
|
+
if counter = invalid then return invalid
|
|
196
|
+
if counter.count() = 0 then return invalid
|
|
197
|
+
return counter
|
|
198
|
+
end function
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<!--
|
|
3
|
+
Copyright (c) 2026 Roku, Inc.
|
|
4
|
+
SPDX-License-Identifier: MIT
|
|
5
|
+
-->
|
|
6
|
+
<component name="ads" extends="Task">
|
|
7
|
+
<script type="text/brightscript" uri="ads.brs" />
|
|
8
|
+
<interface>
|
|
9
|
+
<field id="debugOutput" type="boolean" value="false" />
|
|
10
|
+
<field id="command" type="string" value="none" />
|
|
11
|
+
<field id="adsState" type="string" value="none" />
|
|
12
|
+
|
|
13
|
+
<field id="adUrl" type="string" value="" />
|
|
14
|
+
<field id="adView" type="node" />
|
|
15
|
+
<field id="contentView" type="node" />
|
|
16
|
+
<field id="contentMetadata" type="assocarray" />
|
|
17
|
+
<field id="getAdsResult" type="array" />
|
|
18
|
+
<field id="showAdsResult" type="string" value="none" />
|
|
19
|
+
<!-- Optional { start, total } ad counter passed to showAds as RAF's ctx. -->
|
|
20
|
+
<field id="adCounter" type="assocarray" />
|
|
21
|
+
|
|
22
|
+
<!-- SSAI specific fields -->
|
|
23
|
+
<field id="stitchedAdResult" type="assocarray" />
|
|
24
|
+
<field id="stitchedAdsInitResult" type="string" value="none" />
|
|
25
|
+
|
|
26
|
+
<!-- CSAS specific fields -->
|
|
27
|
+
<field id="constructStitchedStreamResult" type="string" value="none" />
|
|
28
|
+
<field id="renderStitchedStreamResult" type="string" value="none" />
|
|
29
|
+
|
|
30
|
+
<!-- Audience measurement specific fields -->
|
|
31
|
+
<field id="enableAdMeasurements" type="boolean" value="false" />
|
|
32
|
+
<field id="contentGenre" type="string" value="" />
|
|
33
|
+
<field id="contentId" type="string" value="" />
|
|
34
|
+
</interface>
|
|
35
|
+
</component>
|