A WebXR player for Opencast lecture recordings, built
on sphere-shell (npm: sphere-shell).
Every video track of a recording becomes its own window on the sphere around you — presenter camera, screen recording, whatever else the recording carries — kept on one shared clock, with chapters, a running transcript, head-locked subtitles, series navigation and a transport in the dock. The same code renders immersively on a Quest 3 and as a magic window in an ordinary browser.
Anonymous and read-only against a public Opencast server's Search API. No login, no HLS, no quizzes — see Scope.
npx pnpm@10.4.1 works in place of pnpm if pnpm is not on your PATH:
pnpm install
pnpm dev # HTTPS on all interfaces, port 5190
pnpm test # unit tests
pnpm buildFirst run of dev asks for your password. WebXR requires a secure
context, so the dev server speaks HTTPS via vite-plugin-mkcert, which
installs its root CA into the system keychain. Answer once; it will not ask
again.
Then open the printed URL. On a desktop you get the magic window (mouse-drag
to look, R to recenter); the top-left overlay has the "VR betreten" button
when a headset is present, and otherwise says in one line why WebXR is
unavailable. For a headset, see
docs/QUEST-VALIDATION-PLAYER.md,
which covers the LAN and adb reverse routes and the whole acceptance
checklist.
For a production deployment as a static site (Caddy with automatic Let's
Encrypt — or nginx —, SELinux, firewalld, on Rocky Linux 10), see
docs/INSTALL-rocky-linux-10.md.
Everything you operate while watching is in the dock, the strip below the windows — one place, always in reach, always aimable with a controller ray. In player mode it is one large Play/Pause square spanning two rows, and beside it two rows of everything else (browse mode shows no transport at all):
| Row | Control | What it does |
|---|---|---|
| both | ▶ / ⏸ | Play/pause, as a 60 px square spanning both rows — the biggest target in the strip, because it is the one control you reach for without looking. A spinner glyph replaces it while the wall is buffering. |
| 1 | 0:32 timeline 2:42 |
The timeline, and nothing else, across the whole width of the dock's control block. Click or drag to seek; the target time (and the chapter there, if the recording has segments) previews in the HUD while you drag. The position and duration readouts flank it, where they have always been. |
| 2 | Home > Reihe > Aufzeichnung |
Where you are. Home goes back to the library; Reihe opens the library already showing that series' recordings; the current recording's crumb opens the Reihe window (and closes it again) — it carries a list icon to say so. The series crumb, and the icon, are absent for a recording with no series. |
| 2 | ⏮ ⏭ | Previous/next recording of the series, in the series' own order, skipping ones with nothing to play. Disabled at either end, absent for a series-less recording. Switching never autoplays — the next lecture lands paused at 0. |
| 2 | CC | Subtitles on/off. Greyed out for a recording with no captions. |
| 2 | Aa − 100% + |
Caption size, in 12 % steps of the current size. Only while captions are on. |
| 2 | ▲ ▼ | Moves the caption up or down, 3° of pitch a press, ±12°. Only while captions are on. |
| 2 | 🔊 / 🔇 | Mute the session. Keeps the volume level — unmuting comes back to exactly where you were. |
| 2 | − 100% + |
Master volume in 10 % steps. Only the master stream carries audio; see the sync engine. |
| 2 | ⓘ | Opens (and closes) the Info window. |
To the right of the app's own controls sit sphere-shell's two: a ... menu
holding Arrange, Recenter and the experimental Curved/Flat toggle, and — only
inside a session — a red X that ends it (hover it for an "Exit VR" label).
The timeline takes its width by construction, not by a constant. The column of two rows sizes itself to its widest child, which is always row 2; row 1 stretches to that width; and only the track grows into what row 1 has left over. So it re-solves itself when the caption buttons appear, when the breadcrumb changes, or when a recording has no series.
Only the video windows, and as large as the comfortable field of view allows: one stream gets 64° of azimuth centred straight ahead, a pair gets 52° each at ±27°. Both are derived from a usable arc of about ±55° (a Quest 3 sees roughly 110° at once) and from the dock's own −30° elevation, not chosen by eye. A third stream and beyond keeps the earlier layout: 40° mains, 24° flanks.
Kapitel, Transkript, Reihe and Info start closed, each as a dock tile — the same closed-window tile the shell already uses, so getting one back is the click you already know. The Reihe window also opens from the breadcrumb's last crumb and Info from the dock's ⓘ. Opening a different recording from within player mode leaves whatever you arranged alone; the panels only start closed when you enter player mode from the library.
Caption size defaults small on purpose. uikit's pixel-to-meter conversion is fixed at 0.01 m/px, which makes the caption panel's raw design size about 5.6 m wide hanging 1.2 m from your eyes — inside a magic-window frustum only about 2.7 m wide. The scale runs 0.09 to 0.32 with 0.16 the default; the ladder is multiplicative (12 % of the current size per press), because a fixed increment is a 22 % jump at the small end and a 6 % nudge at the large one. The readout is a percentage of the default, so "100 %" is where everyone starts.
The mechanism is one factor multiplied into the caption panel's own design pixels — font size, padding, corner radius and max width together, which is a uniform scale rather than a reflow, and keeps uikit's SDF glyphs crisp at any factor. Only the caption scales; the seek-feedback readout it shares the HUD with stays a fixed size.
▲/▼ move the whole head-locked HUD's resting pitch, ±12° around its default of 15° below your gaze — far enough to get the captions off a subtitle burned into the video, not far enough to put them on the video's middle or down in the dock.
Both settings persist across reloads (one localStorage key,
opencastxr.player.caption). They are accessibility settings: a control that
has to be re-found on every visit gets pressed once and then endured. A storage
that is missing, full, or forbidden is not an error — the setting simply applies
for this session only.
The defaults were retuned after the first headset session ("L ist zu groß … S ist gefühlt auch noch ein wenig zu groß"): the default is now below what used to be the smallest step, with five more presses available underneath it.
The flat page's top-left overlay carries two checkboxes that exist only in a
dev build (import.meta.env.DEV; a production build drops them and the code
behind them entirely), because develop.opencast.org cannot exercise
everything the player does:
| Checkbox | What it does | Why |
|---|---|---|
| Zweiter Stream (Test) | Duplicates the next opened recording's only video track under a second flavor | Most recordings there have a single video flavor, so nothing would exercise the sync engine. Rarely needed now: that server has since gained a genuine two-flavor recording, "Dual-Stream Demo". |
| Kapitel (Test) | Adds three chapter marks (0:00/1:00/2:00) to the next opened recording | No recording on that server publishes slide segments, so the chapters window would never appear. |
Both are read when an episode is opened, so they take effect on the next one. They are DOM overlay controls and therefore invisible inside an XR session — set them before entering VR.
The server URL is code-only: there is no UI field and no environment
variable. The client is constructed with no options at all, so it falls back to
DEFAULT_BASE_URL in src/opencast/client.ts — https://develop.opencast.org.
This is what src/App.tsx actually contains:
const client = useMemo(
() => (import.meta.env.DEV ? new SyntheticDualStreamClient() : new OpencastClient()),
[],
)To point the player elsewhere, pass baseUrl to both constructors — that is
the whole change:
const client = useMemo(
() => {
const options = { baseUrl: 'https://opencast.example.org' } // <- added
return import.meta.env.DEV ? new SyntheticDualStreamClient(options) : new OpencastClient(options)
},
[],
)(SyntheticDualStreamClient extends OpencastClient and declares no
constructor of its own, so it accepts the same options.) Vite's HMR picks the
edit up without a restart.
The server must send permissive CORS headers, since the browser talks to it directly and anonymously.
OpencastClientOptions carries the two seams the data layer was designed
around, both unused in v1 and both fully tested:
authorize(init, url) => RequestInit— shapes every request the client makes, API calls and caption fetches (real deployments ACL-gate WebVTT files too). This is where a JWT bearer header, an LTI-derived token, orcredentials: 'include'for a session cookie goes.resolveAssetUrl(url) => string— rewrites every media/image URL that leaves the data layer: track URLs, episode thumbnails, segment previews. This is where signed-URL minting goes. One caveat, documented at the call site: caption-track selection scores a.vttsuffix as a weak secondary signal, so a rewrite that strips the suffix leaves that choice resting on the MIME type alone.
Auth itself (session cookie, LTI, JWT) is deliberately out of v1.
A production build (vite build, never pnpm dev) reports three kinds of
anonymous "hit" to /api/hit, same-origin, via src/telemetry.ts:
{"kind":"page"}once per page load, on mount.{"kind":"vr"}/{"kind":"ar"}the first time that page load actually enters animmersive-vr/immersive-arsession (read fromxrStore.getState().mode— the ACTUAL granted mode, not just what was requested). Re-entering the same mode again in the same page load sends nothing further.
This is meant to be received by the small standalone service in
counter/ — see that README for exactly what it does
and does not retain (short version: per-day, per-country totals plus a
VR/AR/browser split; no IP address is ever written to disk, and no
video/episode identifier is ever sent to it at all). A deployment with no
such service behind /api/hit behaves identically to one with it: every
send is wrapped so any failure (404, timeout, unreachable host) is silent —
see telemetry.ts's module doc comment.
Because the beacon only ever runs in a production build, the corresponding
privacy note on the start screen ("Anonyme Nutzungsstatistik: …", see
App.tsx) is likewise only rendered outside import.meta.env.DEV. An
operator who deploys without the counter service can simply not
reverse-proxy /api/hit — the note is accurate either way (a beacon that goes
nowhere still leaves no video-level or IP-level record), but if you would
rather not ship it at all in that case, removing both the useEffects and
the note's <span> in App.tsx is a three-line diff.
Three layers, the lower two React-free and fixture-tested:
src/
├── opencast/ Search-API client, tolerant response parsing, track/quality
│ selection, WebVTT parser, auth + asset-URL hooks
├── player/ media-element lifecycle, the sync engine (master clock, drift
│ bands, stall handling, master election and handover), and the
│ zustand store that owns both
└── windows/ one uikit component per window, each with its decision logic
split into a pure, unit-tested `*State.ts` sibling
The split in windows/ is not decoration: @react-three/uikit components
cannot render meaningfully in jsdom, so anything worth asserting on lives
outside the component and the component stays thin glue. App.tsx composes
the two modes (browse = library window; player = video windows + controls +
chapters + series + transcript + HUD + dock transport).
Two rules worth knowing before editing:
- The shell owns "is this window on screen"; the store owns "is this stream
loaded". Closing a stream must go through the shell
(
shellStore.close(videoWindowId(f))), neverstore.closeStream()directly — a bare store call is silently undone by the watcher that reconciles the two. See the doc comments inplayer/store.tsandwindows/videoWindowState.ts. VideoSurfacenever touches playback. Playback belongs entirely to the sync engine; a window unmounting only drops a texture.
window.__opencastPlayer is published in every build (see
verificationHandle.tsx): pump() forces frames, store is the player
store, and sample() returns one numeric snapshot of every stream — master,
play state, per-stream currentTime, muted, paused, readyState. It is
how every drift and handover number in this project was measured, and it works
over chrome://inspect against a headset.
Out of v1 by decision, not by omission: authentication (the hooks above exist), quizzes, HLS/adaptive streaming, the Opencast External API, a spatial gallery browse mode, and any interactive head-locked UI (aiming at UI that moves with your head is a VR anti-pattern — the timeline lives in the dock, and the HUD only ever displays).
- Curved-window mode scrubs imprecisely. With the experimental "Curved"
mode on (the dock's
...menu), timeline drags land up to ~6.4 % off near the panel's edges: the bend is a vertex-shader effect the pointer maths cannot see. Clamping and monotonicity still hold and flat mode, the default, is exact. A proper fix needs a newsphere-shellAPI (a dock bend frame, or a ray-correction helper) and is deferred to the next library round. - Minimized streams keep decoding. Minimizing drops the texture, not the playback — by design, so the stream stays on the shared clock and comes back in sync. The cost is decode plus bandwidth for a window you cannot see.
- The last open stream's ✕ is inert.
sphere-shell0.3.0 has noclosableprop, so the refusal happens after the shell has already closed the window: it is closed and immediately restored, costing one unmount/remount frame and a silent focus change. - Non-playable episode tiles still show a hover affordance, even though clicking them does nothing.
- Six or more video flavors overlap by about a degree of azimuth (no real recording has that many).
- Long caption lines are hard-wrapped by character count and can break mid-word.
- The head-locked HUD has never been seen through a headset's lenses. Keystone/shear in stereo and the caption + seek-feedback stacking are the top items on the hardware checklist.
@react-three/uikit1.0.74 has several reproduced defects this app works around (ahover={undefined}reconciler crash, missing glyphs for typographic punctuation, a many-wrapped-lines rendering limit, stalee.pointunder pointer capture). Readdocs/UIKIT-NOTES.mdbefore re-diagnosing one from scratch.
| Where | What |
|---|---|
docs/QUEST-VALIDATION-PLAYER.md |
The hardware checklist: how to reach a dev server from a headset, the Definition of Done as checks, and what is still unverified. |
docs/design-spec.md |
The design spec this was built from — error cases (§9) and the Definition of Done (§11) included. |
docs/UIKIT-NOTES.md |
Real @react-three/uikit defects and their workarounds. |
sphere-shell |
The window-shell API this app is written against. |
docs/INSTALL-rocky-linux-10.md |
Production deployment as a static site on Rocky Linux 10 (Caddy + Let's Encrypt, alternatively nginx + certbot; SELinux, firewalld) — German, for university admins. |
counter/README.md |
The standalone anonymous visitor counter service: endpoints, what is/isn't retained, GeoIP setup, configuration, tests. |
Apache License 2.0. Copyright 2026 Universität Osnabrück,
virtUOS. Author: Rüdiger Rolf. See NOTICE for attribution.