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.
- An in-world
BS.Browsershows a full-screen player page. 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. |
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 |
Only space moderators may host or control. See the caveat below. |
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" writes all shared state to the protected space-state scope and
restricts hosting and control to space moderators. It is enforced on two levels:
- The snippet refuses to claim hosting or write anything when the local user is not a moderator.
- Packet Party enforces protected writes server-side once the prefix is protected.
The caveat: nothing currently tells the client whether the local user is a moderator.
The SideQuest API computes is_space_admin in its Packet Party join webhook, but that
flag never reaches the client, and the bridge's ss_me_admin sentinel is therefore
always absent. While the sentinel is absent the snippet keeps the gate open (a
closed gate would lock every room out) and only the scope change takes effect. Once
the server passes the flag through, the same build starts enforcing it with no changes
here.
Plain Node with no runtime dependencies. Every response carries Access-Control-Allow-Origin: *.
| Endpoint | Purpose |
|---|---|
GET /healthz |
Liveness. |
GET /snippet.js |
The snippet bundle, no-cache. |
GET /player.html, /player.js, /vendor/hls.min.js |
The player page. |
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=....
npm install # dev-only dependencies (esbuild, typescript, hls.js, ws)
npm run build # bundles src/snippet -> public/snippet.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. 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.
- Moderator enforcement is client-side until the server passes the moderator flag through.
- 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.