36 KiB
Architecture
How StreamXR is built today, for people and AI agents changing the code. Read this before changing structure. The why behind the big decisions is in docs/decisions/, what changed when is in CHANGELOG.md, and the code rules are in CONTRIBUTING.md. What's been learned about YouTube and the hardware, the interface's design choices and the open questions are in docs/ (youtube.md, hardware.md, design-choices.md, known-issues.md).
StreamXR is a Manifest V3 browser extension that plays a website's own <video> in a
SteamVR headset through WebXR: 360° and VR180 videos as a sphere around you, regular videos
on a cinema screen, with a floating control panel worked by laser pointers. It has no
build step and one vendored library (Google's Material Color Utilities, third_party/, for
the Material 3 palettes): the repo folder is the extension.
Three layers
- Shell (site-agnostic), in two parts. It never names a site and never reads a page;
it only knows the provider interface.
- Core (
src/core/): the VR interface any VR browser feature could use, and the future core of BrowseXR (docs/Future-Plans.md): controllers and input, the lasers, the controls panel, the layout with its toolbars, and the five panel styles, the keyboard, the Laser / Keyboard / Language settings, translations, storage, the site registry. - StreamXR (
src/streamxr/): the video player built on the core: the WebXR session and its frame loop, the media source, rendering (video sphere, cinema screen, pause overlay), the video formats, the 2D Cinema settings, the "Watch in VR" popup, and the page-world entry point.
- Core (
- Provider (
src/sites/<site>/page/provider/, one per site): the middle layer. It answers the shell's questions ("what's playing?", "fill this panel", "this was clicked") by calling the site files, and turns site data into the shell's generic shapes (cards, tabs, pills, text, rows, "now playing"). - Site files (
src/sites/<site>/page/, e.g.youtube-*.js): the only code that reads, clicks or requests anything from the site. They expose what the site offers and don't know the shell exists.
The rule: features flow shell ← provider ← site. A shell file never gets a site-specific field or check; when a site needs something new, add a generic option (a model field, a card flag, a slot, a provider hook) and let the provider fill it.
Supported sites: YouTube (src/sites/youtube/).
Two JavaScript worlds
A content script runs in one of two worlds on the site's page, which can only talk through
DOM events carrying JSON strings (src/core/shared/bridge.js):
- Page world (
"world": "MAIN";src/core/page/,src/streamxr/page/,src/sites/<site>/page/): the site's own context. WebXR sees the headset here and the site's player lives here. All VR rendering happens here. - Isolated world (
src/streamxr/ui/,src/sites/<site>/ui/): the extension's sandbox. The page button and the popup live here, because a site's Trusted Types policy (YouTube's) blocksinnerHTMLin the page world.
There are no ES modules (content scripts can't be modules). Every file is
(function (ns) { ... })(globalThis.__streamxr = globalThis.__streamxr || {}); and adds
one piece to that namespace (each world has its own copy); manifest.json loads the files
in dependency order. The manifest has one pair of content_scripts entries per site
(page world + isolated world), matched to the site's host, listing the core's files first,
then StreamXR's, then the site's (and StreamXR's main.js last). Chrome keeps sites apart
by page, so supporting another site needs no build step.
Shared files: Chrome runs a given script file at most once per page even when two
entries list it, so the isolated world can't load the shared/ folders (src/core/shared/,
src/streamxr/shared/). It loads generated copies in each one's ui/shared/; after
editing a shared/ folder, run node tools/sync-shared.js (it also checks the manifest for
that mistake).
Folder map
manifest.json load order for both worlds, one pair of entries per site; the icons (icons/), also
as the toolbar `action` (no popup: it only gives a pinned icon and its tooltip)
src/core/ THE CORE (shell, part 1): the VR interface, site-agnostic
shared/ used by BOTH worlds (page loads these; ui loads copies)
bridge.js event names + send/on/ask helpers (JSON-string payloads)
i18n.js translations: t(key) in the interface language (or the browser's)
strings-en.js the core's words in English (strings-<lang>.js: other languages)
controls.js what each controller input does (single source of truth)
ui/shared/ GENERATED copies of core/shared/ (tools/sync-shared.js)
page/ page world
math3d.js mat4 / vector helpers, head yaw
pose-smoother.js steadies the laser's pose against hand tremor (One Euro filter)
gl-utils.js shader programs, VAOs, buffers, textures
storage.js remembered settings (streamxr.* in localStorage)
sites.js the site registry: each site registers its provider; current()
frame-clock.js waits that keep time in VR (ticked by the frame loop)
diagnostics.js WebXR checks + headset clues + controller reports (Details)
input.js controller state -> actions, via controls.js
controllers/
config.js fit offsets, colours, button->part map, icon placement <- tune here
icons.js icon artwork: Material Symbols on M3E tonal buttons, in the colour theme (canvas 2D)
models.js GENERATED controller meshes (tools/controller-models/)
model-pass.js draws the meshes
icon-pass.js draws the floating icons
controllers.js per-hand pose, press highlights, stick tilt; runs both passes
vr-panel/
config.js layout distance and text size, panel sizes, auto-hide, laser look <- tune here
tokens.js design tokens: colours by Material 3 role, the site's theme, glass
glyphs.js the M3E loading indicator (`spinner`), textMiddle
symbols.js Material Symbols icons (Rounded; outlined + filled) as Path2D outlines
panel-ui.js the controls panel: layout, drawing, hit testing
styles/ the chevron panels' five styles + shared pieces
kit.js generic card, images, badge registry, the M3 tabs (drawTabs, headerTabs), the M3 button, the hover ring, regionAt (hit tests; small controls reach out to 56 dp), createStrip (a row that slides sideways when the laser nears its end)
grid.js tabs, pills, text field, card grid, paging (Catalog + Profile)
thread.js a threaded comment list (Info's Comments, Profile's opened post)
chat.js a live chat list (Info's 'chat' content): rows with inline pictures, cards, a pinned banner
info.js / catalog.js / queue.js / profile.js / options.js
orbiters-ui.js the side toolbars (upright M3E floating toolbars on the Safe Zone line), the pill (Settings) and the search bar; a side toolbar has a grab bar on its video side (moves its whole side)
popups-ui.js the controls panel's floating menus and the dialog (raised in front: Android XR's popup and dialog levels)
motion.js hover / press easing (M3E springs): one tracker per panel canvas; the opening springs (panels without bounce)
laser-options.js the Laser settings tab: steadiness (the shell's own)
appearance-options.js the Appearance settings tab: the colour theme, the glass look on / off and its tint, the panel size, vibration on / off (the shell's own)
fade-options.js the Auto-hide settings tab: how long the controls panel, toolbars, search bar, controllers and lasers wait before hiding / fading, and the "Auto-hide while paused" switch
safe-zone.js the Safe Zone: where the layout goes, as shares of the headset's field of view (measured from the eyes' projections); its outline
safe-zone-options.js the Safe zone settings tab: width, height, centre (the shell's own)
keyboard-options.js the Keyboard settings tab: style and language (or yours), and the layout keyboard.js draws
language-options.js the Language settings tab: the interface language (or the browser's)
hover-card-ui.js floating card (Previous / Next hover in a list)
keyboard.js the in-VR keyboard: keys (layout from keyboard-options.js), Shift, symbols, grab bar
panel-pass.js draws a panel in the world; the glass: the video behind it, blurred (from the renderer's backdrop())
laser-pass.js draws laser beams + hit dot
vr-panel.js the layout around the Safe Zone, laser hits, clicks, scrubbing, auto-hide; builds the
panels, toolbars and pill from the site's registry
src/streamxr/ STREAMXR (shell, part 2): the video player on the core
shared/ used by BOTH worlds
strings-en.js StreamXR's words in English: formats, popup, starting VR, 2D Cinema
formats.js the video formats (names, projection); detection is the provider's
ui/shared/ GENERATED copies of streamxr/shared/ (tools/sync-shared.js)
page/ page world
media-source.js where the frames come from: the site's <video>, followed when swapped
video-sphere.js the video sphere renderer (360°, 180°, cubemap)
cinema-screen.js flat/curved screen for regular videos (rounded corners, the edge-colour glow); picks sphere vs screen per format
cinema-options.js the 2D Cinema settings tab
pause-overlay.js dims the video, pause symbol, buffering loading indicator
captions-overlay.js the site's caption lines drawn in VR (over the 2D screen's bottom, or below your view)
xr-session.js one VR session: setup + per-frame loop (takes a provider)
main.js page-world entry: picks the site's provider (sites.js), answers the popup
ui/ isolated world
goggles-icon.js the VR headset icon (Material Symbols' Head Mounted Device) for the VR buttons and popup
panel.js "Watch in VR" popup (format picker, Enter VR, help, Details)
popup.css its styles (Material 3; colours from the site's palette, asked of the page world)
src/sites/youtube/ YOUTUBE
page/
youtube-data.js plumbing: text helpers, watch-page data, /youtubei/v1 requests,
video/playlist item readers
youtube-player.js the #movie_player API: playback, quality, speed, volume, captions,
audio tracks, loop/autoplay, playlist, live, storyboards, ads
youtube-page.js the watch page: Up Next, details, Subscribe/Like/Dislike, navigation
youtube-content.js metadata, description, chapters, heatmap, end screen, cards,
playlist, comments, transcript, search
youtube-channel.js any channel by id: header, tabs (sortable), playlists, shows ...
youtube-home.js Home feed + topic chips, Explore feeds, menu, search suggestions
youtube-subscriptions.js Subscriptions feed, Manage list, Subscribe/Unsubscribe, the bell
youtube-chat.js a live stream's chat: its views, the chat page, polling, items -> plain messages
provider/
provider.js the provider (interface at its top); registers YouTube in sites.js
panels.js the panel registry (each panel's zone, style, size, controller)
extras.js the controls panel's Captions and Save buttons and Jump ahead at the seek bar's end (the shell's "extras": left, right, seek), premiumMark() (the Diamond)
strings-en.js the YouTube panels' words in English (strings-<lang>.js: other languages)
cards.js, badges.js YouTube items -> generic cards; the ✓ / ♪ badges
upnext.js, browse.js (Explore and You), search.js, channel.js, description.js, comments-panel.js, settings.js, live-chat.js
one controller per panel (comments.js: the comment threads, used by
the Comments panel and a Channel post)
ui/
site.js the site's name and brand colour for the popup (until the palette arrives)
player-button.js, button.css the VR button in YouTube's player controls
topbar-button.js the VR button in YouTube's top bar (next to "+ Create"), on every page
skip-helper.js talks to the optional Skip Helper extension
main.js isolated-world entry: keeps the button in the page
icons/ the extension's icons, 16-128 px (tools/icons/make-icons.html)
skip-helper/ SEPARATE optional extension: presses YouTube's Skip ad button
third_party/material-symbols/ the Material Symbols license (Apache 2.0), for symbols.js
third_party/material-color-utilities/ Google's Material Color Utilities (Apache 2.0), wrapped as a plain
script (ns.materialColor): makes the Material 3 palettes (tokens.js)
tools/sync-shared.js shared/ -> ui/shared/ copies (core and streamxr), manifest checks
tools/icons/make-icons.html draws the extension icons (icons/, skip-helper/icons/) from Material Symbols
tools/controller-models/ dev-only: rebuilds models.js from Valve's CAD (see README)
docs/
youtube.md the YouTube modules + what's been learned about YouTube
hardware.md the Steam Frame and its controllers in Chrome
design-choices.md how the interface is meant to look and behave
known-issues.md unverified, quirky, tried and dropped
Future-Plans.md BrowseXR prep: what's done, what's left, what it would break
Materials-Redesign.md the planned Material Design 3 + glass redesign
features/ backlogs: what YouTube offers that VR doesn't use yet
decisions/ why the big decisions were made
A VR session
- The site's page button (isolated world) opens the popup (
src/streamxr/ui/panel.js). It asks the page world over the bridge for the video info, the provider's detected format, the site's Material 3 palette (its colours) and the WebXR diagnostics, and shows a format picker, the passthrough switch (greyed out unless the browser offersimmersive-ar) and Enter VR. - Enter VR sends
ENTERinside the click (WebXR needs the user gesture). StreamXR'smain.jstakes the provider of the site this page belongs to (ns.sites.current(), registered by the site'sprovider.js) and callsxrSession.start(provider, fmt, listener). xr-session.jsasks for animmersive-arsession if passthrough was chosen and is offered (elseimmersive-vr; a see-through session clears to transparent), applies the provider's theme, opensprovider.session(), builds the video view (video-sphere.jsorcinema-screen.jsby format), the pause overlay, the controller models and the VR panel, then runs the frame loop:- follow the media source (
media-source.js: the site's<video>, which can be swapped when another video opens) and upload a frame only when there is a new one; - poll the controllers (
input.jswith the bindings incontrols.js); - ads (if the provider has
ad()): show them on the cinema screen, then switch back and re-detect the format; - recenter when asked; re-detect the format after
provider.onNavigate; panel.update(...), then draw: video, pause overlay (paused /provider.buffering()), panels, controller models, lasers.
- follow the media source (
- The panel's
actionsare the provider session's plus the shell's own (recenter, exit, setFormat, setDistance, setCurve); itsstate()is the session's plus the format and the cinema screen settings (viewSettings).
The stick: left/right is ±10 s, but while the laser is on a panel with page arrows it turns
pages (panel.stickPage), and on a scrolling area up/down scrolls (panel.stickScrolls);
the controller icon changes to match (panel.iconSwaps).
The provider
The interface is written at the top of src/sites/youtube/page/provider/provider.js, which
is the source of truth. In short:
- Site level:
name,theme({ accent, accentPressed, dark }),themes(optional:[{ id, label, theme }], the colour themes offered in Settings → Appearance, the first the default;appearance-options.jsapplies the chosen one),video(),info(),detectFormat(info?)->{ fmt, isSpatial },isPaused(),toggle(),seek(s),buffering(),onNavigate(fn)->stop(),session(). - A session:
actions: those the shell calls (toggle,seekBy,seekTo,goLive,setQuality,setSpeed,setVolume,setMuted,storyboard,skipAd,previousVideo,nextVideo), plus whatever the site's own panel controllers use.state(), polled every frame:title,nowPlaying,time,duration,live,paused,quality({ current, currentLabel, levels: [{ id, label }] }),speed,speeds,volume,chapters,ad, plus what the site's panels read.ad()(optional): the ad playing now, or null.site:{ name, panels (the registry), onControl(id, panel) }.
- Optional features simply aren't provided: ads, live streams, the storyboard preview, chapters, a queue, action buttons. Quality, speed and volume are not optional yet (the controls panel always shows those buttons).
nowPlaying is what the controls panel draws under the title (panel-ui.js documents
it): info (coloured pieces on one line, e.g. views · date, then the playlist),
creator ({ name, badge, stats }, a button with id creator), actions (right-aligned
buttons: pills with a look and label, or thumbs icons with a count; ids action:<id>;
a hidden one keeps its place), and queue (Previous / Next with their hover cards).
Controls the shell doesn't handle itself (title, creator, action:<id>) go to
site.onControl(id, panel), where panel(id) gives a chevron panel's controller.
Cardinal Direction panels
The site's panels are laid out around the Safe Zone (safe-zone.js; the part of your
view the video keeps; docs/Spatial-Layout.md), each in a zone its registration names:
left and right (outside the zone's side edges, one open per side, remembered; each side
has an upright toolbar, orbiters-ui.js, standing on the zone's line over the panel's
inner edge, its names growing outward), or controls (a popup rising from a pill on the
controls panel's top edge, e.g. Settings), or top (a search bar above the zone, its
suggestions dropping into it, and the panel in the zone's middle while its controller's
model() says open; it also returns the bar's field as bar). The controls panel sits
under the zone; a panel asking for keys (typeInto) brings up the in-VR keyboard. Each
panel is a style (how it looks) filled by a site's controller (what it shows).
The registry
A site lists its panels (YouTube: provider/panels.js, which documents the interfaces):
{ id, zone: 'left' | 'right' | 'controls' | 'top',
label, icon, // icon: a Material Symbols name (symbols.js)
style: 'info' | 'catalog' | 'queue' | 'profile' | 'options',
size: 'wide' | 'tall' | 'standard', // vr-panel/config.js `sizes`
overrides: { slots: { ... } }, // optional: the style's slots
default: true, // optional: open the first time (on its side)
available: st => bool, // optional (side panels): offered only while true
// (asked every frame; its toolbar button hides meanwhile)
autoOpen: true, // optional: opens whenever it becomes available (not
// remembered); the panel it replaced comes back after
stays: true, // optional: doesn't fade with the rest while you watch
create(host) -> controller }
YouTube's Live chat uses all three (available while a live stream with a chat plays):
vr-panel.js refreshAvailable hides its toolbar button (orbiters-ui.js setHidden, the
bar shrinking and laid out again) and keeps a stays panel drawn and updated while the panels
are hidden.
host:{ actions, style, perPage, open(), close(), isOpen(), closeMenu(), openMenu(control), panel(id), confirm(d), typeInto(on, opts) }(opts.emojis: the panel's own emojis,[{ text, image, label }], first on the keyboard's emoji pages);confirm(d)asks in the shell's dialog ({ title, text (up to four lines), confirm, cancel (null: no cancel button, a dialog that only informs), danger }-> a Promise of true / false); the site'sonControl(id, panel, shell)gets the same asshell.confirm.- Menus on side panels:
host.openMenu(control)(e.g.'action:bell') opens a floating menu above that control, 32 dp in front, like the controls panel's (the style needsrectOf(id); Profile has it). While it's up,vr-panel.jsasks the controller'smenu(control)each frame ({ items: [{ id, text, on, disabled, icons }], note, stay },nullcloses it); a choice arrives as{ type: 'menu', value: item id, menu: control }. The menu's id is'p:<panel>|<control>'and its items'pi:<index>'(panelMenu).host.panel(id)is another panel's controller (the You panel opens a channel in the Channel panel with it). - Typing: a panel with a text field calls
host.typeInto(true)to get the keys (andfalseto give them back). One panel types at a time: when another asks, the one that had the keys is sentEscape(as for a click on nothing) and should stop typing. While it's shown, each key reaches its controller as{ type: 'key', value }: a character (or an emoji, or a panel emoji'stext, from the keyboard's 😀 pages),Backspace,EnterorEscape. The keys come from the in-VR keyboard (vr-panel/keyboard.js; thekeyboardbinding swaps it with the controls panel invr-panel.js: it hangs from the controls panel's top edge, sharing its pose) and from the computer's keyboard (kept from the page meanwhile, so YouTube's shortcuts don't fire). controller:model(frame)->{ model, key }(while shown; redrawn only whenkey, the hover or the loaded images change),event(ev), and optionallytick(frame)(every frame),afterDraw(),opened(), plus anything the site'sonControlcalls.- Sizes (canvas px,
config.jssizes):widepanels all take the height a Catalog trims itself to (900 px wide),tallis 900 x 1100,standardis the controls panel's width (1024). Every canvas is shown at the same density (config.jslayout.pxPerDegree, atlayout.distance).
vr-panel.js builds everything from the registry: the layout, the toolbars and the pill, hit testing, events,
stick paging and scrolling, slider dragging, redraw keys and image loading. A panel
control's id there is <panel id>|<style id>.
The five styles
| Style | YouTube panel | Draws |
|---|---|---|
Info (info.js) |
Description, Comments, Live chat | header tabs; each tab one content kind: text (rich text, links), paged (rows in two columns: chapters), rows (scrolling, current row followed: transcript), thread (pills, count, rows with children and "show more": comments), chat (chat.js: a pinned banner, messages with inline pictures and coloured cards, following the newest or paused with a "jump to newest" button; optionally a message field with Send, reaction buttons and a Support button, an XP pill and a ranked Top fans list: live chat), message (optional loading indicator) |
Catalog (catalog.js) |
Explore, You, Search | title, source tabs, filter pills (tabs and left-aligned pills slide when they don't fit: kit.js createStrip) or a note or a text field with its button (and a suggestions list dropping down from it), card grid, paging; trims its height to its cards |
Queue (queue.js) |
Up Next | title or tabs, paging in the header, 3 x 2 cards, a "current" card |
Profile (profile.js) |
Channel | banner, picture, name + badge, stats line, one action button (looks: primary / secondary / danger / busy) and an optional round icon button beside it (the bell), tabs (shrinking to fit beside the pills), pills, a search button (searching: a back button and a text field in place of the tabs), card grid, paging, or one featured card, or a scrolling body: a text page (paragraphs, then label / value facts), a feed of posts (picture, author, text, pictures, a card, a poll with result bars, buttons with icons and counts), or one opened post with a back button and its comments (thread.js) |
Options (options.js) |
Settings | tabs (with icons); up to 3 rows (name with its hint under it; at the right: a switch, a slider with a Default button and end captions, a ‹ value › stepper, or a button; faded) or a 4 x 4 choice grid with "More" paging |
Each style file's header lists its model and control ids. Every style returns
{ canvas, aspect, draw(model, ui), hitTest(u, v), has(id), imagesReady(), perPage };
Info and Profile add scrollAt, maxScroll, shownScroll, pxPerV, Options adds sliderFraction.
Standard events: a style's control ids are <type>:<value> and reach the controller
as { type, value, fraction? }. Types: tab, pill, item, page, action,
featured, post, vote, back, link, row, time, children, more, newest, react, send, support, fans, fansBack, switch, slider, default,
choice, prev, next, button, field, clear, submit, suggest, plus scroll (from dragging or the stick), key (typing), a slot's own ids, and the names a provider gives a post's buttons (YouTube: postlike, postdislike, postcomments).
The generic card (kit.js), used by every card grid and the hover card:
{ title, thumb, corner, flags: [{ text, bg, fg, dot, matchWidth }], lines: [{ text, badge }], current, progress, fit, inert }.
The provider turns site items into cards (YouTube: provider/cards.js, with its 2D / 180° /
360°, LIVE, Mix, Playlist, Short and "Now playing" flags).
Badges are a registry: kit.registerBadge(name, draw, extra); YouTube registers ✓
(verified) and ♪ (artist).
Extending a style
A site changes a style only through slots the style declares, passed as
overrides.slots in the registration; the style keeps control of layout, hit testing,
scrolling and redraws. Today there is one slot: Info's threadFooter (YouTube draws its
comment rows' 👍 / 👎 / ❤ footer with it, provider/comments.js). Two guardrails:
- Data before overrides. Anything that can be said as data (a line, a flag, a label, a colour) is a model field the provider fills; a slot is only for genuinely different drawing.
- One level only. A site customises the shell's style, never another site's. If two sites want the same thing, it becomes an option of the style.
When a site needs a new slot, add it to the style (with a default that draws today's look) and document it in the style's header.
Theme, storage, the popup
- Design tokens (
vr-panel/tokens.js,ns.vrTokens): every colour the panels draw is a Material 3 colour role,color.<role>(primary,surface,onSurface,onSurfaceVariant,outline,tertiary...; today's in-between greys as neutral tonesn90...n13), oralpha(role, a)for translucent layers. Drawing files import them asTC/TAand read them while drawing (never at load), and never write a colour themselves. A provider'stheme({ accent, accentPressed, dark, custom }) is applied when VR starts (apply);customadds the site's own roles (YouTube's card flags). Card flags give colours as role specs ('surface/0.75'), drawn throughresolve.m3is the Material 3 palette (every M3 role, dark scheme), made when VR starts from the provider'stheme.seedby Material Color Utilities (scheme(seed, variant), ~0.4 ms; the theme also picks the variant,scheme, and role pairs toswap), else M3's baseline, plus our rolesbrand/onBrand(the site's exact accent) andhoverRing. Restyled components (so far the controls panel,panel-ui.js) draw fromm3(P,PA(role, a)), with M3 type roles (font(role)) and corner sizes (shape);glassholds the planned glass look's settings (tint 0.62). See docs/Materials-Redesign.md. - Storage (
storage.js,ns.storage): remembered settings in the site's localStorage understreamxr.*(values saved under the older names,streamvr.*andytvr.*, move over when first read). - Site registry (
core/page/sites.js,ns.sites): each site'sprovider.jscallsns.sites.register({ id, name, hosts, provider }); StreamXR'smain.jsasksns.sites.current()for the page it runs on. Page world only (the isolated world's site files are listed per site in the manifest). - The popup (
src/streamxr/ui/panel.js) gets the site's name and dark colour fromsrc/sites/<site>/ui/site.js(ns.site). - Words (
core/shared/i18n.js,ns.i18n): nothing shown to the user is written in the code; it'sns.i18n.t('key', { vars }), looked up in string tables, one per part: the core's (src/core/shared/strings-<lang>.js), StreamXR's (src/streamxr/shared/ strings-<lang>.js; both in both worlds: the popup uses them), and each site's (src/sites/<site>/page/provider/strings-<lang>.js, keys starting with the site, e.g.yt.). English (-en) is complete; another language may leave keys out (they show in English: language, then its base, then English). A text can have plural forms ({ one, other, ... }, picked byvars.n). The language is the browser's unless one is chosen in Settings → Language (localStoragestreamxr.language, read by both worlds; the global reset clears it). Text is asked for at draw time, never kept in constants built at load, so a language change shows at once;ns.i18n.version()is in every redraw key. A registrylabelcan be a function for the same reason. What the site writes itself (titles, view counts, its filter chips) isn't translated, and readers that match the site's own words (e.g. YouTube's "All" chip, "Joined") keep matching them as they are. Adding a language: CONTRIBUTING.md, "Translating".
Where to make common changes
- YouTube changed its page or data: the
youtube-*.jsfile that reads it. - A new YouTube feature in a panel: the panel's controller in
provider/; if the style can't show it yet, add a generic option to the style first. - A new chevron panel: a controller + a registry entry in
provider/panels.js, built on one of the five styles. - New controller action: a binding in
src/core/shared/controls.js, its handler inxr-session.js(the input handler), optionally an icon (Material Symbols names) incontrollers/icons.jsand a placement incontrollers/config.js. The help line updates itself. A binding withhold: secondsfires once after that long held (input.jsholds()feeds the countdown ring drawn over its part's icon). - A new adjustable setting: give its module a
reset()and call it fromresetAllinxr-session.js(the global reset), and forget its stored value withns.storage.remove. - A colour on a panel: a role from
vr-panel/tokens.js(TC.<role>,TA(role, a)), never a literal; a missing role goes into tokens.js under its M3 name. - New words on screen: a key in the English table of the part that draws it
(
src/core/shared/strings-en.js,src/streamxr/shared/strings-en.js, or the site'sprovider/strings-en.js) andns.i18n.t(key)where it's drawn. Never a literal in the code. - New video format:
src/streamxr/shared/formats.js(label, projection), the renderer invideo-sphere.jsorcinema-screen.js, detection in each provider'sdetectFormat. - Tuning numbers:
vr-panel/config.js,controllers/config.js,SETTINGSincinema-screen.jsandpause-overlay.js. - Another headset: see the README's "Adding a headset".
Adding a site
- Check the site's video can be drawn at all (see "Known limits": DRM).
src/sites/<site>/page/: the site files, and aprovider/withprovider.js(the interface above, ending withns.sites.register({ id, name, hosts, provider })), its panels (panels.js+ one controller each, on the five styles) and its string tables.src/sites/<site>/ui/:site.js(name, colours), the page button and its CSS, andmain.js(keeps the button in the page).manifest.json: the host inhost_permissions, and a pair ofcontent_scriptsentries for it (copy YouTube's pair: core files, then StreamXR's, then the site's, thensrc/streamxr/page/main.jslast).- Never change the shell for one site: add a generic option, slot or provider hook.
Gotchas
- In VR, don't wait with
setTimeout: while you're in the headset Chrome can treat the page as hidden and run its timers late (about once a second, after five minutes once a minute). Search suggestions waited on a 150 ms timer and never showed in the headset (0.44.0). Usens.frameClock.sleep(ms)/after(ms, fn)(frame-clock.js, ticked by the VR frame loop; also whatyoutubeData.sleepuses), or a controller'stick(frame). - Chrome injects a given script file only once per page across
content_scriptsentries: hence the generatedui/shared/copies (core's and StreamXR's) for the isolated world. - Shader uniforms shared between a vertex and a fragment shader must use the same precision, or the program fails to link.
- When blending overlays, keep the destination alpha intact:
blendFuncSeparate(..., ZERO, ONE). - Images drawn on panel canvases must load with
crossOrigin='anonymous'(and the server must allow it), or the canvas can no longer be uploaded as a WebGL texture. - WebXR's
requestSessionmust be called inside the user's click (the popup sendsENTERsynchronously from its click handler). - Each panel is redrawn only when its controller's key, the hover or the loaded images change: anything a panel shows must be in its key, or the panel won't update.
- Site-specific gotchas (YouTube's data, player and ads) are in docs/youtube.md.
Known limits
- DRM. Netflix, HBO Max, Disney+ and most paid services protect video with Widevine. Chrome won't copy protected frames into WebGL (black or an error), and every video is drawn through WebGL, so those services can't be supported unless that changes.
- WebXR needs the page's permissions policy to allow
xr-spatial-tracking; a site can block it (diagnostics.jsreports it). - Sites' player APIs are private and change; a provider may have to click the site's own
buttons (as
youtube-page.jsdoes). YouTube's text matching assumes English. - Desktop Firefox has no WebXR, so the VR part can't run there yet; browser-specific code
is kept to
src/sites/youtube/ui/skip-helper.jsandskip-helper/. - There are no automated tests in the repo. The maintainer tests in a headset; refactors of drawing code are checked by rendering the canvases from mock data before and after and comparing them pixel for pixel.