@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/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>