mirror of
https://github.com/FauxxYT/StreamXR.git
synced 2026-10-23 09:00:36 +02:00
486 lines
36 KiB
Markdown
486 lines
36 KiB
Markdown
# 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/](docs/decisions/), what changed when is in [CHANGELOG.md](CHANGELOG.md),
|
||
and the code rules are in [CONTRIBUTING.md](CONTRIBUTING.md). What's been learned about
|
||
YouTube and the hardware, the interface's design choices and the open questions are in
|
||
[docs/](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
|
||
|
||
1. **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](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.
|
||
2. **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").
|
||
3. **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)
|
||
blocks `innerHTML` in 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)
|
||
collection.js a collection page (a playlist opened from its card: picture, details, buttons, rows; Catalog + Profile)
|
||
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; playlist.js: a playlist opened
|
||
from its card, used by Explore, You, Search and the Channel panel)
|
||
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
|
||
|
||
1. 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 offers `immersive-ar`) and **Enter VR**.
|
||
2. **Enter VR** sends `ENTER` inside the click (WebXR needs the user gesture). StreamXR's
|
||
`main.js` takes the provider of the site this page belongs to (`ns.sites.current()`,
|
||
registered by the site's `provider.js`) and calls `xrSession.start(provider, fmt, listener)`.
|
||
3. `xr-session.js` asks for an `immersive-ar` session if passthrough was chosen and is
|
||
offered (else `immersive-vr`; a see-through session clears to transparent), applies the
|
||
provider's theme, opens `provider.session()`, builds the
|
||
video view (`video-sphere.js` or `cinema-screen.js` by 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.js` with the bindings in `controls.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.
|
||
4. The panel's `actions` are the provider session's plus the shell's own (recenter, exit,
|
||
setFormat, setDistance, setCurve); its `state()` 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.js` applies 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`, `playable` (false: the site won't play this video;
|
||
Play greys out), plus what the site's panels read.
|
||
- `poster()` (optional): while the site won't play the video, picture URLs (best first) the
|
||
screen shows in its place (`xr-session.js` `posterCanvas`).
|
||
- `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: `pill`s with a look and label, or thumbs `icon`s 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):
|
||
|
||
```js
|
||
{ 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's `onControl(id, panel, shell)` gets the same as `shell.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 needs `rectOf(id)`;
|
||
Profile has it). While it's up, `vr-panel.js` asks the controller's `menu(control)` each frame
|
||
(`{ items: [{ id, text, on, disabled, icons }], note, stay }`, `null` closes 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 (and
|
||
`false` to give them back). One panel types at a time: when another asks, the one that had
|
||
the keys is sent `Escape` (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's `text`, from the
|
||
keyboard's 😀 pages), `Backspace`, `Enter` or `Escape`. The keys come
|
||
from the in-VR keyboard (`vr-panel/keyboard.js`; the `keyboard` binding swaps it with
|
||
the controls panel in `vr-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 when `key`,
|
||
the hover or the loaded images change), `event(ev)`, and optionally `tick(frame)` (every
|
||
frame), `afterDraw()`, `opened()`, plus anything the site's `onControl` calls.
|
||
- Sizes (canvas px, `config.js` `sizes`): `wide` panels all take the height a Catalog trims
|
||
itself to (900 px wide), `tall` is 900 x 1100, `standard` is the controls panel's width
|
||
(1024). Every canvas is shown at the same density (`config.js` `layout.pxPerDegree`, at
|
||
`layout.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, or a `collection` page under a back button (collection.js: an opened playlist, scrolling); 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, or a `collection` page (collection.js: the playing playlist, or one opened from a card under a back button; scrolling) |
|
||
| **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 `tiers` page (plans or levels to join: title, price, button, items with small pictures, a note), 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), or a `collection` page with a back button (collection.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:
|
||
|
||
1. **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.
|
||
2. **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 tones
|
||
`n90` ... `n13`), or `alpha(role, a)` for translucent layers. Drawing files import them as
|
||
`TC` / `TA` and read them while drawing (never at load), and never write a colour
|
||
themselves. A provider's `theme` (`{ accent, accentPressed, dark, custom }`) is applied
|
||
when VR starts (`apply`); `custom` adds the site's own roles (YouTube's card flags).
|
||
Card flags give colours as role specs (`'surface/0.75'`), drawn through `resolve`.
|
||
`m3` is the Material 3 palette (every M3 role, dark scheme), made when VR starts from the
|
||
provider's `theme.seed` by Material Color Utilities (`scheme(seed, variant)`, ~0.4 ms;
|
||
the theme also picks the variant, `scheme`, and role pairs to `swap`), else M3's
|
||
baseline, plus our roles `brand` / `onBrand` (the site's exact accent) and `hoverRing`.
|
||
Restyled components (so far the controls panel, `panel-ui.js`) draw from `m3` (`P`,
|
||
`PA(role, a)`), with M3 type roles (`font(role)`) and corner sizes (`shape`); `glass`
|
||
holds the planned glass look's settings (tint 0.62). See
|
||
[docs/Materials-Redesign.md](docs/Materials-Redesign.md).
|
||
- **Storage** (`storage.js`, `ns.storage`): remembered settings in the site's localStorage
|
||
under `streamxr.*` (values saved under the older names, `streamvr.*` and `ytvr.*`, move
|
||
over when first read).
|
||
- **Site registry** (`core/page/sites.js`, `ns.sites`): each site's `provider.js` calls
|
||
`ns.sites.register({ id, name, hosts, provider })`; StreamXR's `main.js` asks
|
||
`ns.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 from
|
||
`src/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's `ns.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 by `vars.n`). The language is the browser's unless one is
|
||
chosen in Settings → Language (`localStorage` `streamxr.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 registry `label` can 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-*.js` file 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 in
|
||
`xr-session.js` (the input handler), optionally an icon (Material Symbols names) in `controllers/icons.js`
|
||
and a placement in `controllers/config.js`. The help line updates itself. A binding with
|
||
`hold: seconds` fires once after that long held (input.js `holds()` feeds the countdown
|
||
ring drawn over its part's icon).
|
||
- **A new adjustable setting:** give its module a `reset()` and call it from `resetAll` in
|
||
`xr-session.js` (the global reset), and forget its stored value with `ns.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's
|
||
`provider/strings-en.js`) and `ns.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 in
|
||
`video-sphere.js` or `cinema-screen.js`, detection in each provider's `detectFormat`.
|
||
- **Tuning numbers:** `vr-panel/config.js`, `controllers/config.js`, `SETTINGS` in
|
||
`cinema-screen.js` and `pause-overlay.js`.
|
||
- **Another headset:** see the README's "Adding a headset".
|
||
|
||
## Adding a site
|
||
|
||
1. Check the site's video can be drawn at all (see "Known limits": DRM).
|
||
2. `src/sites/<site>/page/`: the site files, and a `provider/` with `provider.js` (the
|
||
interface above, ending with `ns.sites.register({ id, name, hosts, provider })`), its
|
||
panels (`panels.js` + one controller each, on the five styles) and its string tables.
|
||
3. `src/sites/<site>/ui/`: `site.js` (name, colours), the page button and its CSS, and
|
||
`main.js` (keeps the button in the page).
|
||
4. `manifest.json`: the host in `host_permissions`, and a pair of `content_scripts` entries
|
||
for it (copy YouTube's pair: core files, then StreamXR's, then the site's, then
|
||
`src/streamxr/page/main.js` last).
|
||
5. 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). Use `ns.frameClock.sleep(ms)` / `after(ms, fn)` (`frame-clock.js`, ticked by
|
||
the VR frame loop; also what `youtubeData.sleep` uses), or a controller's `tick(frame)`.
|
||
- Chrome injects a given script file only once per page across `content_scripts` entries:
|
||
hence the generated `ui/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 `requestSession` must be called inside the user's click (the popup sends `ENTER`
|
||
synchronously 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](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.js` reports it).
|
||
- Sites' player APIs are private and change; a provider may have to click the site's own
|
||
buttons (as `youtube-page.js` does). 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.js` and `skip-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.
|