A shared, synced video player for Altspace spaces (the Greenfield client). It is an
external app snippet: one <bs-snippet> element in the space page loads
snippet.js from this Bonto app, which builds the whole player out of Creator SDK
objects. No Unity-side code ships with it.
- Live app: https://videoplayer.bonto.run
- Source:
c:\workspace\VideoPlayer - Companion core change:
Assets/Systems/Networking/SpaceStateBridge.csinSideQuest.Greenfield.Unity, which connects the SDK's space-state API to Packet Party room state. Without it the player still runs, but nothing is shared.
- The in-world screen draws the space page's own browser view (the SDK's
asset_browser_worldtexture reference), with the player page laid over that page, so no second browser is opened and there is one player per space. The SDK must provide the world browser texture (BSLink.WORLD_BROWSER_TEXTURE) and!swbs!sizing. YouTube videos and live streams use the YouTube IFrame API; direct video links, HLS and Google Drive files use<video>(with hls.js where the browser has no native HLS); Twitch channels and VODs use the Twitch embed. - A small control panel sits in the top-right corner of the screen: transport, mute, volume, resolution and a Playlist button.
- The playlist opens as a curved screen in front of the viewer, with tabs for the queue, saved lists, search and people. It follows the viewer with smoothing.
- The first person in the space becomes host. The host drives playback; everyone else follows. Hosting can be locked, transferred or pinned, and taken over automatically when the host leaves.
- Playback position and the playlist live in space state, so late joiners land where the room is and the queue survives everyone leaving.
Every attribute of the <bs-snippet> element is an inspector field in Unity. The
inspector types each field from its value: true/false becomes a toggle, one
number a float, two to four numbers a vector, anything else text. Attribute names are
kebab-case because HTML lowercases them.
| Attribute | Default | Meaning |
|---|---|---|
position |
0 1.5 0 |
World position of the screen centre. |
rotation |
0 0 0 |
World Euler angles of the screen, which faces its local +Z (0 180 0 faces a viewer at the origin from +Z). |
width |
1.6 |
Physical screen width in metres, 16:9. |
resolution |
1080p |
Browser page size: 720p, 1080p, 1440p, 2160p. Capped to 1080p on Android/Quest. |
channel |
(empty) | State namespace. Empty uses the first 8 characters of the snippet instance id. Two snippets with the same channel are one show on two screens. Must contain a letter. |
playlist |
(empty) | YouTube playlist id or URL that seeds the queue once. |
playlists |
(empty) | Up to 5 comma-separated playlist ids preloaded as saved lists. |
video |
(empty) | Single item queued first when the room has no playlist. |
idle-video |
(empty) | Played on a loop whenever nothing is queued. |
allow-sources |
yt,ytlive,twitch,url,drive |
Which kinds users may add. |
autoplay |
true |
Host starts playing as soon as a queue exists. |
loop |
true |
Wrap to the first item after the last. |
locked |
false |
Pre-lock: only the host may change anything, and the lock toggle is hidden. |
mods-only |
false |
Keep shared state in the protected (private) scope, which only space moderators may write. |
host-uid |
(empty) | Pin hosting to one SDK uid. The People tab shows uids. |
volume |
80 |
Starting volume for each viewer, 0-100. |
muted |
false |
Start muted locally. |
distance-volume |
true |
Fade the volume with the viewer's distance from the screen. |
full-volume-distance |
3 |
Metres within which the volume is untouched. |
silence-distance |
18 |
Metres at which the volume reaches its floor. |
min-volume |
0 |
Floor percentage of the viewer's own volume, 0-100. |
controls |
true |
Show the corner control panel. |
panel-distance |
1.2 |
Distance from the viewer to the playlist screen. |
panel-width |
1.6 |
Arc width of the playlist screen. |
curved |
true |
Curved playlist screen. false gives a flat panel. |
sync-tolerance |
1.5 |
Seconds of drift before a peer seeks. |
max-items |
100 |
Queue cap, 1-300. |
debug |
false |
Verbose logging plus window.__vp[channel] diagnostics. |
<bs-snippet name="video-player" title="Video Player"
description="Synced player for YouTube, Twitch, direct video links and Google Drive files with a shared playlist. Drag the handle to place the screen; the plane shows the default 1.6 m width. Keep this GameObject at identity."
script="https://videoplayer.bonto.run/snippet.js"
position="0 1.5 0" rotation="0 0 0" width="1.6" resolution="1080p"
channel="" playlist="" playlists="" video="" idle-video="" allow-sources="yt,ytlive,twitch,url,drive"
autoplay="true" loop="true" locked="false" mods-only="false" host-uid=""
volume="80" muted="false" distance-volume="true" full-volume-distance="3" silence-distance="18" min-volume="0"
controls="true" panel-distance="1.2" panel-width="1.6" curved="true"
sync-tolerance="1.5" max-items="100" debug="false">
<bs-gizmo type="position" attribute="position"/>
<bs-gizmo type="plane" attribute="position" size="1.6 0.9" rotation="0 0 0"/>
</bs-snippet>Keep the BSSnippet GameObject at identity, or the gizmo preview stops matching the
runtime placement. The plane gizmo always shows the default 1.6 m width: gizmo size is
static markup and cannot follow the width attribute.
| Kind | Accepted input | Backend |
|---|---|---|
| YouTube video | id, youtu.be/…, watch?v=… |
YouTube IFrame API |
| YouTube live | /live/<id>, or a watch id the server reports as live |
YouTube IFrame API, seeking disabled |
| Twitch channel | twitch.tv/<channel> |
Twitch embed, seeking disabled |
| Twitch VOD | twitch.tv/videos/<id> |
Twitch embed |
| Direct video | any URL that serves video/* (mp4, webm, mov, ogv) |
<video> |
| HLS | .m3u8 URL |
<video> natively, else hls.js |
| Google Drive | /file/d/<id>, open?id=, uc?id= |
<video> through /api/drive/<id>/media |
Direct links need no CORS headers to play. HLS through hls.js does, because hls.js fetches the manifest itself. Google Drive files must be shared "anyone with the link".
Browser audio never enters Unity, so there is no true spatial audio and no AudioLink feed. Two things approximate it:
- Per-viewer volume. The volume slider and mute are local. Nothing about audio is written to space state.
- Distance volume. The snippet attaches a hidden anchor to the local avatar's head,
measures the distance to the screen and scales that viewer's volume before sending it
to the page. Inside
full-volume-distancenothing changes; beyond it the volume follows an inverse rolloff down tomin-volumeatsilence-distance. Turn it off withdistance-volume="false". If the head anchor never reports a pose, the volume stays where the viewer set it.
Real spatial audio needs a core change: audio redirection on the browser prefab plus Ora's WebRTC audio path. It is a follow-up, not part of this app.
mods-only="true" moves every shared record (host, playback, playlist, saved lists)
from the public space-state scope into the protected scope. Packet Party guards
protected writes with its set-room-state action, which is answered by the SideQuest
API webhook, so only space moderators can write. Everyone else still reads the state
and watches along.
Nothing tells a page outright whether its user is a moderator; the snippet goes by what the Greenfield bridge reports and by what happens to its own writes. It marks itself read-only (stops claiming hosting, hides the controls, shows "Mods only · view") as soon as any of these is true:
ss_can_protectis"0": the bridge will not even attempt a protected write (for example, the user is signed out).ss_errreports a denial (not_authorized,not_space_admin,jwt_missing, …) for one of this channel's keys. Transient codes such asrate_limitedare ignored.- Two of its writes in a row never come back.
It retries once a minute, so promoting someone to moderator takes effect without a reload.
For this to work the app's webhook must handle the set-room-state action. In the
SideQuest API that is PacketPartyService.handleSetRoomState, which resolves the
writing client through the join-time client-to-user mapping and allows the same people
kick and ban allow: SideQuest admins, the world owner, or a community owner/moderator.
Plain Node with no runtime dependencies. Every response carries Access-Control-Allow-Origin: *.
| Endpoint | Purpose |
|---|---|
GET /healthz |
Liveness. |
GET /snippet.js |
A fixed loader that fetches app.js?t=<now>. This is the URL spaces name. |
GET /app.js |
The snippet bundle. |
GET /player.html, /player.js, /vendor/hls.min.js |
The player page. The page is served naming player.js?v=<version>. |
GET /api/search?q=&limit= |
YouTube search. |
GET /api/playlist/:id |
YouTube playlist contents, continuations followed. |
GET /api/resolve?input= |
Classify a pasted link or id into a playable item or a playlist. |
GET /api/drive/:id/media |
302 to Google's stream for a public Drive file. |
GET /test/player-harness.html |
Desktop harness: drives the player page with a stubbed Ora bridge. |
Environment variables (all optional):
| Variable | Effect |
|---|---|
GOOGLE_API_KEY |
Enables YouTube Data API v3 as the metadata provider and the keyed Drive alt=media redirect. Without it the server uses innertube and the legacy Drive download path. |
PORT |
Listen port, default 3000. |
Set one with bonto apps update videoplayer --env GOOGLE_API_KEY=....
Caching. Cloudflare sits in front of Bonto and caches scripts and pages at the edge
for hours, whatever Cache-Control says. It does key on the query string, so freshness
comes from URLs:
snippet.jsnever changes, so a stale edge copy of it is harmless.- The loader fetches the bundle as
app.js?t=<now>, which is always a cache miss. - The bundle opens
player.html?…&v=<build>, a new URL for every build. - The server stamps
player.js?v=<mtime,size>into that page.
A deploy is therefore live on the next space load.
npm install # dev-only dependencies (esbuild, typescript, hls.js, ws)
npm run build # bundles src/snippet -> public/app.js and copies hls.min.js
npm run typecheck # tsc --noEmit
npm test # server unit tests + snippet unit tests
npm start # serve on PORT (default 3000)
npm run deploy # push server.js, lib/, public/ to the Bonto appnode tools/deploy.js --dry-run lists what would be uploaded. A real deploy checks
that the server process actually restarted (its /healthz uptime drops), retries the
restart once, and fails loudly if it never does. node tools/pp-probe.mjs
joins a throwaway Packet Party room and reports what room state accepts; pass --jwt <token> to test protected operations as a signed-in user.
The snippet imports the Creator SDK type declarations from Greenfield through the
@sdk/* path alias, which points at
../SideQuest.Greenfield.Unity/Packages/com.sidequest.injection/Types~. Regenerate them
from Injection~ with tsconfig.types.json when the SDK changes.
- No spatial audio and no AudioLink, as above.
- Playlist and playback state are capped at 24 KB encoded per channel, which is roughly 100 items with long URLs. The UI says "playlist full" rather than silently dropping items.
- Search is YouTube only. Twitch search would need Twitch developer credentials.
- Google Drive playback depends on Google's download quota and only works for files shared with anyone who has the link.
- YouTube videos whose owners disable embedding report error 150 and are skipped.
- Hosting is keyed by the SDK uid. If a build ships with
_forceUniqueIdentityPerLaunchenabled, uids change every launch and a reconnecting host is treated as a new user.