Skip to content

Repository files navigation

Altspace Video Player (external app snippet)

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.cs in SideQuest.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.

What it does

  • An in-world BS.Browser shows 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.

Parameters

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.

Markup

<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.

Media sources

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".

Audio

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-distance nothing changes; beyond it the volume follows an inverse rolloff down to min-volume at silence-distance. Turn it off with distance-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.

Moderator-only mode

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.

Server

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=....

Development

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 app

node 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.

Limitations

  • 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 _forceUniqueIdentityPerLaunch enabled, uids change every launch and a reconnecting host is treated as a new user.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages