Hello World with default values
By the end of this page the Maestro SDK is running inside your own app on your target device, rendering the panel for Maestro's demo page, showing an overlay, and moving focus between your UI and the SDK. Overlays, specific panels, authentication, analytics, error handling all come after this.
Everything here uses default values against Maestro's public demo site. You do not need an account, a page of your own, or an event feed.
- Site ID:
69b2f133e8117ec6536d59a1 - Page ID:
6aa966d669342216ada5a4b8
When you move to your own site, the Site ID is window.INIT.SITE._id in the browser console on your Maestro site, and the Page ID is shown under the page name in its Settings. The end-to-end sequence these sections implement is drawn in Architecture, and the rule about where the SDK lives in your app is here.
Pick your platform once. The choice follows you through every section on this page.
Start with the sample pull request
Each platform has an example repo with the app on one branch and the same app with the SDK on another. The pull request between them is the integration, and it is the fastest way to see every change in one diff.
- Swift (tvOS)
- Kotlin (Android TV / Fire TV)
- BrightScript (Roku)
- JavaScript (Web, Samsung, LG)
maestro-tvos-sdk-example, pull request 2: main is the app without the SDK, feat/basic-sdk-integration is the app with it.
git clone https://github.com/lessthan3/maestro-tvos-sdk-example.git
cd maestro-tvos-sdk-example && git checkout feat/basic-sdk-integration
open "Hello World.xcodeproj"
Run the scheme on the tvOS Simulator or an Apple TV. Xcode 16, tvOS 18. MaestroKit and its Rive dependency resolve through Swift Package Manager.
maestro-android-sdk-example, pull request 1: feat/initial-app is the app without the SDK, feat/maestro-sdk-integration is the app with it.
git clone https://github.com/lessthan3/maestro-android-sdk-example.git
cd maestro-android-sdk-example && git checkout feat/maestro-sdk-integration
Put a GitHub username and a personal access token with the read:packages scope in local.properties as githubUsername and githubPass; the SDK is on GitHub Packages. Open in Android Studio and run on an Android TV emulator or Fire TV.
maestro-roku-sdk-example, pull request 1: main is the app without the SDK, Hello-World is the app with it.
git clone https://github.com/lessthan3/maestro-roku-sdk-example.git
cd maestro-roku-sdk-example && git checkout Hello-World
Set your device IP and developer password in bsconfig.json, then sideload with the VS Code BrightScript extension's launch configuration. The device needs developer mode and the same network as your computer.
maestro-bbd-sdk-example, pull request 2: main is a React app without the SDK, Hello-World is the app with it.
git clone https://github.com/lessthan3/maestro-bbd-sdk-example.git
cd maestro-bbd-sdk-example && git checkout Hello-World
yarn && yarn start
Opens at http://localhost:3000. Node 18, React 16 through 19.
Maestro Panel
Instantiate the SDK once at the app level with the Site ID, configure it for the demo page with the Page ID, and give the panel a place next to your player. The panel renders nothing until the page configuration has loaded.
- Swift (tvOS)
- Kotlin (Android TV / Fire TV)
- BrightScript (Roku)
- JavaScript (Web, Samsung, LG)
Add the package https://github.com/lessthan3/MaestroKit.swift in Xcode. Configure in your App initialiser; MaestroManagerDelegate has one method, for analytics events.
import SwiftUI
import MaestroKit
@main
struct Hello_WorldApp: App {
@State private var viewModel = ContentViewModel()
init() {
MaestroManager.shared.configure(
siteID: "69b2f133e8117ec6536d59a1",
jwt: "",
maestroManagerDelegate: AppDelegate(),
maestroWorkingEnvironment: .prod,
defaultPanel: .stats
)
}
var body: some Scene {
WindowGroup {
ContentView().environment(viewModel).environment(\.colorScheme, .dark)
}
}
}
final class AppDelegate: MaestroManagerDelegate {
func trackAnalyticsEvent(name: String, attributes: [String: String]) {}
}
Start the event when the user picks content. On tvOS the eventID parameter is where the Page ID goes. MaestroEventDelegate is how the SDK talks back; every method is required, and for Hello World only the panel and overlay ones do anything.
@MainActor
@Observable
class ContentViewModel: MaestroEventDelegate {
var isShowingPanel = false
private var eventInterface: MaestroEventInterface?
func start() async {
eventInterface = await MaestroManager.shared.userDidStartWatchingEvent(
eventID: "6aa966d669342216ada5a4b8", // the Page ID
delegate: self,
hideBetsPanel: false, hideBetsWagers: false,
disableBetsOverlays: false, disableFantasyOverlays: false
)
isShowingPanel = true
}
func shouldShowPanel() { isShowingPanel = true }
func shouldHidePanel() { isShowingPanel = false }
// Stubs for Hello World
func userRequestedNewKeyPlaysData() {}
func playClip(atIndex index: Int) {}
func shouldShowOverlay(buttonSize: CGSize, overlayType: OverlayType, payload: MaestroOverlayEvent?) async {}
func shouldHideOverlay() async {}
func userViewedPanel(panel: MaestroPanelType) {}
func trackAction(analytics: [String: String]) {}
func trackImpression(analytics: [String: String]) {}
func userRequestedLogin() async {}
func playPauseButtonPressed() {}
func shouldShowPanelType(panel: MaestroPanelType) async {}
}
MaestroPanel() is a SwiftUI view with a fixed width of 676 points. Put it in a trailing column beside your player.
HStack(spacing: 0) {
VideoPlayerView().focusSection()
if viewModel.isShowingPanel {
MaestroPanel()
.frame(width: 676)
.transition(.move(edge: .trailing))
}
}
.task { await viewModel.start() }
android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true // required by the SDK
}
buildFeatures { compose = true }
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.2")
implementation("com.lessthan3:maestropanel:4.0.4")
}
Configure once with the Site ID and Page ID and keep the returned Instance. Do this in your Application class or a root ViewModel, not in the player screen. MaestroEventDelegate has ten required methods; for Hello World they log and return empty data.
val sdk: MaestroSDK.Instance = MaestroSDK.configure(
context = applicationContext,
maestroEventDelegate = MaestroDelegate(),
params = MaestroSDKParameters(
siteId = "69b2f133e8117ec6536d59a1",
pageId = "6aa966d669342216ada5a4b8",
),
)
class MaestroDelegate : MaestroEventDelegate {
override fun keyPlaysData() = MutableStateFlow(MaestroLoadableResult.Success(MaestroKeyPlaysResponse()))
override fun authData(): StateFlow<MaestroAuthData?> = MutableStateFlow(null)
override fun shouldShowPanel() {}
override fun shouldHidePanel() {}
override fun playClip(index: Int, clipId: String?) {}
override fun onKeyPlaysRefreshNeeded() {}
override fun trackImpression(analytics: Map<String, String>) {}
override fun trackAction(analytics: Map<String, String>) {}
override fun onLoginClicked() {}
override fun onPanelSelected(panel: MaestroPanelType) {}
}
Do not call configure again when content changes; it detaches the previous instance. MaestroPanel is a composable that takes the instance. On TV it sets its own width, so the modifier is for placement.
Box(modifier = Modifier.fillMaxSize()) {
YourPlayerContent()
MaestroPanel(
sdk = sdk,
modifier = Modifier.width(300.dp).align(Alignment.CenterEnd),
)
}
Nothing goes in your manifest. The SDK is a remote ComponentLibrary fetched at runtime. Create it in your root scene, not in the video component, so the node outlives any single piece of content.
sub initSDK()
m.componentLibrary = createObject("roSGNode", "ComponentLibrary")
m.componentLibrary.observeField("loadStatus", "onLibraryLoadStatusChanged")
m.componentLibrary.uri = "https://roku-sdk.us-central1-master.gcp.maestro.io/4.1.16/maestrokit.pkg"
end sub
sub onLibraryLoadStatusChanged()
if m.componentLibrary.loadStatus = "ready"
m.lib = createObject("roSGNode", "MaestroPanelLib:MaestroPanel")
m.lib.observeField("showPanel", "onShowPanel")
m.lib.observeField("handleLoadingSceneAction", "onHandleLoadingSceneAction")
m.lib.observeField("errorInformation", "onErrorInformation")
m.top.appendChild(m.lib)
m.lib.config = {
siteID: "69b2f133e8117ec6536d59a1",
pageID: "6aa966d669342216ada5a4b8",
useProdEnv: false
}
end if
end sub
Register observers and append the node before setting config; setting config starts network requests and validates synchronously, so errorInformation can fire before your next line runs. The SDK requires siteID plus pageID or eventID. To switch content, set config again; the node stays in the scene.
Show the panel and make room for it:
sub onShowPanel(event)
if event.getData()
m.video.width = 1184 : m.video.height = 666 : m.video.translation = [50, 200]
else
m.video.width = 1920 : m.video.height = 1080 : m.video.translation = [0, 0]
end if
end sub
' from your own button or key handler
m.lib.showPanel = true
npm install @maestro_io/maestro-web-sdk
The SDK is a singleton. Configure it once when the app mounts, then start the event with the Page ID and your delegate when the user picks content. IMaestroEventDelegate has twelve required methods; the sample repo's AppDelegate.ts is the minimal implementation.
import SDK, { IMaestroEvent } from "@maestro_io/maestro-web-sdk";
import { AppDelegate } from "./AppDelegate";
const SITE_ID = "69b2f133e8117ec6536d59a1";
const PAGE_ID = "6aa966d669342216ada5a4b8";
const delegate = new AppDelegate();
const eventViewModel = useRef<IMaestroEvent | null>(null);
useEffect(() => { SDK.configure({ siteID: SITE_ID }); }, []);
// when the user picks content
eventViewModel.current = await SDK.userDidStartWatchingEvent({
pageId: PAGE_ID,
delegate,
useProdEnv: false,
});
There is no React component for the panel. Provide an element that is already in the DOM and ask the SDK to render into it. Keep the returned unmount function.
const unmountPanel = useRef<(() => Promise<void>) | null>(null);
const showPanel = async () => {
setPanelVisible(true);
unmountPanel.current = await SDK.renderPanel("panel-section");
await eventViewModel.current?.didShowPanel();
};
// next to the player in your JSX
<div id="panel-section" className={panelVisible ? "panel-section" : "panel-section panel-hidden"} />
Maestro Overlay
Overlays appear over the video. The SDK schedules them against playback timecode, so the host has two jobs: give the overlay a place to render, and feed the current timecode. Without timecode, timecoded overlays never fire.
- Swift (tvOS)
- Kotlin (Android TV / Fire TV)
- BrightScript (Roku)
- JavaScript (Web, Samsung, LG)
The SDK asks your delegate to show and hide the overlay. Keep the type and payload it gives you and render MaestroOverlay over the player.
// in ContentViewModel
var overlay: (type: OverlayType, payload: MaestroOverlayEvent?)?
func shouldShowOverlay(buttonSize: CGSize, overlayType: OverlayType, payload: MaestroOverlayEvent?) async {
overlay = (overlayType, payload)
await eventInterface?.didShowOverlay()
}
func shouldHideOverlay() async {
overlay = nil
await eventInterface?.didHideOverlay()
}
// in ContentView, over the player
ZStack {
VideoPlayerView()
if let overlay = viewModel.overlay {
MaestroOverlay(
buttonPosition: CGPoint(x: 1100, y: 100),
overlayType: overlay.type,
payload: overlay.payload
)
}
}
Feed timecode from your player's periodic time observer:
eventInterface?.updatePlayerTimeCode(timeCode: currentTimeMilliseconds)
Overlays are a second composable that takes the same instance. Place it over the player; enableAutoFocus lets the overlay take D-pad focus when it appears.
Box(modifier = Modifier.fillMaxSize()) {
YourPlayerContent()
MaestroPanel(sdk = sdk, modifier = Modifier.width(300.dp).align(Alignment.CenterEnd))
MaestroOverlays(
sdk = sdk,
modifier = Modifier.align(Alignment.TopCenter).wrapContentSize(),
enableAutoFocus = true,
)
}
Feed timecode from your player's position updates:
sdk.onPlayerTimecodeUpdated(positionMillis)
Overlays render inside the MaestroPanel node you already appended. The SDK asks permission before each one through onOverlayEvent; answer will-appear within about 100 ms or it is skipped.
m.lib.observeField("onOverlayEvent", "onOverlayEvent")
sub onOverlayEvent(event)
overlayEvent = event.getData()
payload = overlayEvent.payload
if overlayEvent.type = "will-appear"
m.lib.setDataToOverlay = { overlayId: payload.overlayId, canAppear: true, canFocus: false }
else if overlayEvent.type = "did-dismiss"
if not payload.focusRestoredBySDK then m.settingConfig.setFocus(true)
end if
end sub
Feed timecode on the video node's position tick, as epoch milliseconds in a LongInteger:
m.video.observeField("position", "onVideoPosition")
sub onVideoPosition(event)
t = createObject("roDateTime")
ms& = (t.AsSecondsLong() * 1000) + t.GetMilliseconds()
m.lib.callFunc("onPlayerTimeCodeUpdated", ms&)
end sub
For the demo page the current wall clock is correct. A real integration pushes the wall-clock time the current frame corresponds to.
Overlays need no host element; the SDK appends its own container to document.body when the event starts. Your only job is to answer will-appear in the delegate, synchronously, and to feed timecode.
onOverlayEvent(event: OverlayEvent): void {
if (event.type === "will-appear") {
event.payload.callback({
canAppear: true,
canFocus: false,
suggestedPosition: { x: 64, y: 96 }, // x from the right edge, y from the top
});
}
}
If you never answer, the SDK waits two seconds, applies the defaults, and shows the overlay anyway.
// Epoch milliseconds: a wall-clock base plus the playback position.
const baseTimeCode = Date.now();
videoElement.addEventListener("timeupdate", () => {
const positionSeconds = Math.floor(videoElement.currentTime);
eventViewModel.current?.updatePlayerTimeCode(baseTimeCode + positionSeconds * 1000);
});
Focus management
Focus moves in two directions: your app hands it to the SDK when the user opens the panel or an overlay, and the SDK hands it back when they leave. Your app never forwards key events; each SDK owns input while it has focus.
- Swift (tvOS)
- Kotlin (Android TV / Fire TV)
- BrightScript (Roku)
- JavaScript (Web, Samsung, LG)
tvOS focus does the work. The panel applies its own .focusSection(); give your player column one too and the Siri Remote moves between them. When the user backs out of the panel, the SDK calls shouldHidePanel() and your flag hides the column. Tell the SDK when you show or hide the panel yourself:
await eventInterface?.didShowPanel()
await eventInterface?.didHidePanel()
D-pad and back handling live inside the panel's Compose subtree; your only job is to let Compose focus reach it. When the user backs out, the SDK calls shouldHidePanel() on your delegate. Toggle your own visibility state there and tell the SDK:
sdk.didShowPanel(MaestroPanelType.STATS)
sdk.didHidePanel()
Move focus into the panel with setPanelFocus. Take it back when the SDK sets handleLoadingSceneAction to "returnFocus". Two traps: the showPanel observer fires while the SDK still holds focus, so restore focus a frame later, and restore it to a concrete focusable node, not the Scene.
' open
m.lib.showPanel = true
m.lib.callFunc("setPanelFocus")
' the SDK hands focus back
sub onHandleLoadingSceneAction(event)
if event.getData() = "returnFocus" then m.settingConfig.setFocus(true)
end sub
' handle Back yourself as well, so it closes the panel instead of exiting the channel
function onKeyEvent(key as string, press as boolean) as boolean
if press and key = "back" and m.lib <> invalid and m.lib.showPanel
m.lib.showPanel = false
return true
end if
return false
end function
The SDK owns a keydown listener for arrows, Enter, and Back, including the Samsung and LG remote key codes. It starts paused until you hand focus in.
// into the panel, for example on ArrowRight from your toggle button
await eventViewModel.current?.startFocusManagement({ toTarget: "sidebar" });
When the user backs out, the SDK calls startFocusManagement on your delegate. Focus your own control there:
async startFocusManagement({ fromTarget }: StartClientFocusManagementParams): Promise<void> {
if (fromTarget === "sidebar") window.dispatchEvent(new CustomEvent("maestro:hide-panel")); // your app calls hidePanel()
document.getElementById("panel-toggle-btn")?.focus();
}
Hide the panel by unmounting what renderPanel returned, then refocus your button:
const hidePanel = async () => {
setPanelVisible(false);
await unmountPanel.current?.();
unmountPanel.current = null;
document.getElementById("panel-toggle-btn")?.focus();
};
Click overlay, show panel
An overlay's call to action often opens the panel on the tab it advertises. The SDK drives the transition; your app's job is to make sure the panel is visible and focused when it does.
- Swift (tvOS)
- Kotlin (Android TV / Fire TV)
- BrightScript (Roku)
- JavaScript (Web, Samsung, LG)
When the viewer engages an overlay whose action opens a panel, the SDK calls shouldShowPanel() and then shouldShowPanelType(panel:) on your delegate with the target tab. Your shouldShowPanel already shows the column; nothing else is required for the panel to switch.
func shouldShowPanelType(panel: MaestroPanelType) async {
isShowingPanel = true
}
On engagement the SDK calls shouldShowPanel() on your delegate, switches the panel to the advertised tab itself, and reports the selection through onPanelSelected(panel). Make the panel visible in shouldShowPanel and the rest happens inside the panel.
override fun shouldShowPanel() { panelVisible.value = true }
override fun onPanelSelected(panel: MaestroPanelType) { Log.d(TAG, "Opened via overlay: $panel") }
On did-engage the SDK opens the panel to the overlay's associated tab and sets panelOpenedThroughOverlay to true. Observe it, and make sure your showPanel observer has already made room and moved focus, because the SDK sets showPanel for you in this path.
m.lib.observeField("panelOpenedThroughOverlay", "onPanelOpenedThroughOverlay")
sub onPanelOpenedThroughOverlay(event)
if event.getData() then print "[maestro] panel opened from overlay"
end sub
To open a specific tab yourself, set openToPanel:
m.lib.showPanel = true
m.lib.openToPanel = "stats"
m.lib.callFunc("setPanelFocus")
On engagement the SDK emits did-engage with ctaType: "show_panel". Render the panel if it is not already mounted; the SDK opens the advertised tab inside it.
onOverlayEvent(event: OverlayEvent): void {
if (event.type === "did-engage" && event.payload.ctaType === "show_panel") {
window.dispatchEvent(new CustomEvent("maestro:show-panel")); // your app calls showPanel()
}
// will-appear handling from the overlay section above
}
You are done when
- The panel renders with the tabs configured for the demo page.
- An overlay appears over the video and dismisses itself.
- Focus moves from your UI into the panel and back using the remote or keyboard, and Back closes the panel without leaving the app.
- Engaging an overlay opens the panel on the advertised tab.
- The device log or browser console shows no SDK errors. On Roku,
Session config rejectedonerrorInformationmeans a missing Site ID or Page ID; the authToken and SWID notices are informational for the demo page.