Jukebox is a shared-music Topia app that turns a single dropped asset into a curated, in-world player. Admins search YouTube and build a per-asset catalog of approved tracks. Visitors who interact with the asset browse that catalog and add tracks to a live queue. When one song ends the app advances to the next; when the queue is empty and someone adds the first track, playback starts immediately.
The app runs in two modes: Karaoke (video + audio) or Jukebox (audio only). Admins pick the mode, the display name, and a custom banner image from the in-app Admin tab. Live state is fanned out to every open drawer over Redis-backed Server-Sent Events so the "Now Playing" and queue lists stay in sync across visitors.
- The jukebox asset — any Topia dropped asset you attach the Interactive URL to. Clicking it opens the drawer. The catalog, queue, playback state, and settings all live on this asset's data object, so each dropped asset is its own independent jukebox.
- Now-playing media — the same asset streams the currently playing YouTube track via
updateMediaTypewithsyncUserMedia: true, so everyone in audio range hears (and optionally sees) the same thing in sync. musicNote_floatparticle — fires above the asset each time a new track starts playing.
- Now Playing + Next Up — Home page shows the current track and the ordered queue. Admins can select queued tracks and remove them or press Skip to Next Song.
- Add a Song — visitors browse or type-filter the catalog, select one or more tracks, and add them to the queue.
- Admin tab — Admins get an extra tab (see below) that adds catalog curation and settings.
- Live updates — the drawer subscribes to an SSE stream and reacts to
nowPlaying,addedToCatalog,removedFromCatalog,addedToQueue, andremovedFromQueueevents without a manual refresh.
- Access — visit the drawer as an admin (Topia world admin) and select the Admin tab. Changes affect only this jukebox asset.
- Settings — pick the mode (
jukebox/karaoke), override the display name, and set a custom banner image URL. Switching mode callsupdateMediaTypeimmediately so the current track re-loads with the rightisVideoflag. - YouTube search — search directly against the YouTube Data API from the drawer (25 results per page,
safeSearchconfigurable via env). Duration is fetched per-video and stored in the catalog so it can be shown without another API call. - Catalog curation — add search results to the catalog, or remove tracks from the catalog. Removed tracks are also purged from the queue. The catalog is re-validated against YouTube on every admin load — tracks that YouTube has since removed are flagged with
exists: false. - Queue control — remove queued tracks or skip to the next available one. Removing the currently playing track automatically advances playback.
- Adding to an empty queue when nothing is playing starts the first track immediately (via
updateMediaType) and firesplaysanalytics. - When a track ends, the asset's built-in media completion hits the public
POST /webhook/nextroute, which advances to the next available track. Tracks that YouTube has removed are skipped over. - If the queue empties,
updateMediaType({ mediaType: NONE })clears playback.
Jukebox operates on the specific dropped asset the visitor clicks — its assetId is read from the iframe credentials. There is no unique-name lookup in the codebase.
| Unique Name | Required | Description |
|---|---|---|
| none | – | The jukebox is bound to the interactive asset itself (credentials.assetId); no fixed unique name is used. |
Note: any dropped asset with the Interactive URL and Player Session Credentials enabled can act as a jukebox. State is per-asset, so dropping two assets gives you two independent jukeboxes.
The primary and only store. Attached to the dropped asset the visitor clicks and deleted with it. Initialized lazily by initializeJukebox on first fetch.
{
catalog: Video[]; // Admin-curated tracks (see Video shape below)
queue: string[]; // Ordered list of videoIds pulled from catalog
nowPlaying: string; // videoId currently playing, or "-1" when stopped
settings: {
mode: "jukebox" | "karaoke";
name: string; // Display name override; empty falls back to mode default
imageUrl: string; // Banner image override; empty falls back to jukebox_bg.png
};
}The Video shape stored on catalog:
{
id: { videoId: string };
snippet: {
title: string;
publishedAt: string;
thumbnails: { high: { url: string } };
};
duration: number; // Milliseconds, parsed from YouTube ISO 8601
exists?: boolean; // Populated only in admin GET /jukebox responses
}Not used. Jukebox does not read from or write to world, visitor, or user data objects.
Live sync uses Redis pub/sub fanning out to browser SSE.
| Channel | Direction | Payload |
|---|---|---|
${INTERACTIVE_KEY}_JUKEBOX |
Server → Server (Redis) | { event: "nowPlaying", videoId, nextUpId } or { event: "mediaAction", kind, videos, assetId, visitorId, interactiveNonce } |
GET /api/sse |
Server → Client | Fan-out of the above, filtered so a client does not receive its own mediaAction echo. Client sends heartbeats to POST /api/heartbeat every 5 min. |
All routes mount under /api unless noted. Interactive query params (assetId, interactiveNonce, interactivePublicKey, urlSlug, visitorId, profileId) are auto-attached by the client-side Axios interceptor.
| Method | Route | Auth | Description |
|---|---|---|---|
| GET | / |
– | Hello ping. |
| GET | /system/health |
– | Server version, start date, and env-var SET/NOT-SET status. |
| GET | /system/interactive-credentials |
– | Validates the visitor's session credentials against INTERACTIVE_KEY. |
| GET | /is-admin |
– | { isAdmin: boolean } for the current visitor. |
| GET | /jukebox |
– | Returns the data object. For admins, each catalog entry is annotated with exists after a YouTube existence check. Fires views analytics. |
| GET | /sse |
– | Opens the Server-Sent Events stream. Requires interactiveNonce in the query. |
| POST | /heartbeat |
– | Keeps the SSE connection alive (client pings every 5 min; server prunes stale connections after 15 min). |
| POST | /add-media |
mixed | Body { videos, type }. type: "catalog" is admin-only and takes Video[]. type: "queue" takes string[] of videoIds. If queue was empty and nothing is playing, starts the first track. |
| POST | /remove-media |
admin | Body { videoIds, type }. type: "catalog" also removes matching entries from the queue. If the currently playing track is removed, advances playback. |
| POST | /next |
admin | Skips to the next available track. Falls back to mediaType: NONE if the queue is empty. |
| POST | /search |
admin | Body { q, nextPageToken }. Proxies YouTube Data API v3 search with per-video duration lookup. |
| POST | /settings |
admin | Body Partial<{ mode, name, imageUrl }>. Validates mode against "jukebox" | "karaoke"; a mode change also re-invokes updateMediaType on the current track. |
Mounted separately at /webhook.
| Method | Route | Auth | Description |
|---|---|---|---|
| POST | /webhook/next |
– | Called by the Topia asset when the current media completes. Same handler as POST /api/next; reads credentials from the request body. |
All mutating routes use updateDataObject({..., lock: { lockId, releaseLock: false } }) with a rounded-timestamp lockId to prevent duplicate playback advances or concurrent catalog edits — collisions return HTTP 409.
Fired via the SDK's updateDataObject({}, { analytics }) mechanism on the jukebox dropped asset.
| Event | Fired when | Where | Unique key |
|---|---|---|---|
views |
Any visitor opens the drawer. | GET /jukebox |
profileId (per profile) |
addsToCatalog |
Admin adds tracks to the catalog. | POST /add-media (type: "catalog") |
urlSlug (per world) |
addsToQueue |
Visitor adds tracks to the queue. | POST /add-media (type: "queue") |
profileId (per profile) |
plays |
A track starts playing (first-track-on-empty-queue, or on next). |
POST /add-media, POST /next, POST /webhook/next |
urlSlug (per world) |
settingsUpdates |
Admin saves settings. | POST /settings |
urlSlug (per world) |
There are no per-mode variants and no external analytics sinks (no Google Sheets, no BigQuery).
Create a .env file in the root directory. See .env-example for a template.
| Variable | Description | Required |
|---|---|---|
INTERACTIVE_KEY |
Topia interactive app key. Enforced at startup. | Yes |
INTERACTIVE_SECRET |
Topia interactive app secret. Enforced at startup. | Yes |
GOOGLE_API_KEY |
YouTube Data API v3 key. Required for POST /search and catalog existence checks. |
Yes |
INSTANCE_DOMAIN |
Topia API domain. Defaults to api.topia.io. Use api-stage.topia.io for staging. |
No |
INSTANCE_PROTOCOL |
https or http. Defaults to https. |
No |
REDIS_URL |
Redis URL for the pub/sub bus that powers SSE fan-out. | Yes |
REDIS_CLUSTER_MODE |
"true" to use redis.createCluster, otherwise a single client is used. |
No |
SAFE_SEARCH |
Passed through to YouTube search — "moderate", "strict", or "none". |
No |
AUDIO_ONLY |
If truthy, DEFAULT_SETTINGS.mode defaults to "jukebox" instead of "karaoke". |
No |
PORT |
Server port. Defaults to 3000. |
No |
NODE_ENV |
Node environment. development enables verbose Redis/SSE logs and CORS for :3001. |
No |
API_KEY |
Surfaced in /system/health. Not required by the current SDK init. |
No |
- Click your account image (top-left in-world) → Integrations.
- Create a key pair at https://topia.io/t/dashboard/integrations.
- Add
INTERACTIVE_KEYandINTERACTIVE_SECRETto your.env. - Enable Add Player Session Credentials to Asset Interactions for your developer public key so the drawer receives session credentials.
# from the app root
npm install
cd client && npm install && cd ..
# create a .env at the app root (see Environment Variables above)
cp .env-example .env
# run the dev server (concurrently runs server + client via Vite)
npm run dev- Media source: YouTube only.
SearchVideoscalls YouTube Data API v3 withvideoEmbeddable: "true"; playback links resolve tohttps://www.youtube.com/watch?v=<videoId>and are handed toDroppedAsset.updateMediaTypewithsyncUserMedia: trueso every listener stays in sync. - Track advance on end: Topia's asset media-completion callback hits
POST /webhook/next. That handler reads credentials fromreq.body, not query, so the webhook config on the asset must be set to send them there. - Skip-unavailable logic:
getAvailableVideosbatches videoIds into groups of 50 and checks YouTube'svideos.listbefore advancing. If none remain, playback stops. - Locks & idempotency: every mutating write uses
updateDataObject(..., { lock: { lockId, releaseLock: false } })withlockIdrounded to a coarse timestamp bucket. Concurrent conflicting writes return 409. - SSE lifecycle: the client opens
/api/sseon load, receivesnowPlaying/mediaActionevents, and posts/heartbeatevery 5 min. The server sweeps connections older than 15 min without a heartbeat. - Mode default: if
settings.modeis unset on the asset, playback falls back toAUDIO_ONLY ? "jukebox" : "karaoke"atupdateMediaTypetime.
- SDK Developer docs
- View it in action: Dev, Prod
- Notion One Pager
