10 Commits
Author SHA1 Message Date
Fauxx 3a0c1f0d13 Release v0.88.1: live chat, chat replays, movable side panels (#3)
## What and why

Release **v0.88.1** (`develop` → `main`). No issue: this is the
maintainer's release merge, not an outside contribution.

It brings everything since v0.78.9: 
- **live chat** (reading, reactions, typing with emoji pages, Top fans)
- **chat replays**
- **movable side panels**
- **hide everything with one trigger pull**
- **Auto-hide while paused**. 
It also adds Quality menu icons, clearer controller icons, and fixes.
The full list is in `CHANGELOG.md` (0.78.10 to 0.88.1) and the README's
"What's new".

## How

- Live chat: `src/sites/youtube/page/youtube-chat.js` (YouTube's chat
requests) and the panel controller
`src/sites/youtube/page/provider/live-chat.js`, drawn by the new shell
style `src/core/page/vr-panel/styles/chat.js`. The shell only got
generic registry options (`available`, `autoOpen`, `stays`), no
chat-specific fields.
- Movable side panels, and recentering that keeps your moves:
`vr-panel.js`, `orbiters-ui.js` and `math3d.js` (`invertRigid`), with
the "Recenter resets panels" switch in `safe-zone-options.js`.
- Trigger-to-hide and "Auto-hide while paused" (suggested by
u/TehSuperFruitSnack): `vr-panel.js`, `fade-options.js`.
- Docs: the README (What's new, a help-test list for the live chat, the
Meta Quest 2 reported working by u/TehSuperFruitSnack),
`docs/youtube.md` (Live chat findings), `design-choices.md`,
`whats-left.md`, `known-issues.md`, `hardware.md`.
- The automatic summary flags `styles/info.js` for naming YouTube: those
are two comments in the Top fans part, not code.

## Tested on

- Headset and controllers: Valve Steam Frame with its controllers
- VR runtime / connection: SteamVR, streamed with "Stream VR without
game"
- Browser and version: Google Chrome 155.0.8059.82 (Official Build)
(64-bit) (cohort: Control)
- Signed in to YouTube: yes
- What I tried in VR: each version from 0.79.0 to 0.88.1 as it was
built. Live chat on live streams (reading, reactions, typing, Top fans,
retry while loading), chat replays (plays along, pause, seek), moving
both sides and recentering (with and without "Recenter resets panels"),
trigger-to-hide playing and paused, Auto-hide while paused on and off.

## Checklist

What the automatic summary and the Checks can't see:

- [x] There's an agreed issue for this, and this PR does only that one
thing. *(Release merge by the maintainer; no issue.)*
- [x] I understand every line of this change and can explain it in
review.
- [x] I tested it in a real VR session on the hardware listed above.
- [x] YouTube access stays in `src/sites/youtube/page/youtube-*.js`;
tunables are in the config files.
- [x] Every new file and function has its comment, and comments I
touched match the code.
- [x] New files are in `manifest.json` in the right **order** (the
summary only checks they're listed).
- [x] No new permissions, or they were agreed in the issue.

<!-- pr-summary:start -->
## Automatic summary

<!-- Filled in by .github/workflows/pr-summary.yml on every push; edits
here are replaced. -->

**FauxxYT** wants to merge `FauxxYT:develop` into `main`: 9 commits, 36
files (+2084 −171).

- ⚠️ No linked issue: add `Closes #<issue>` under "What and why" (an
agreed issue is required).
- ✅ Version: 0.78.9 → 0.88.1
- ✅ `CHANGELOG.md` updated
- ✅ Every source file is listed in `manifest.json`
- ✅ `ARCHITECTURE.md` updated
- ⚠️ The shell should name no site, but added lines mention YouTube in:
`src/core/page/vr-panel/styles/info.js` (fine in comments giving an
example; otherwise move it to the provider).

#### Commits (9)

| Commit | Message | Author |
|---|---|---|
|
[`5dfddfb`](https://github.com/FauxxYT/StreamXR/commit/5dfddfb746e15b862181305495944d8ad162c598)
| Minor change to `CHANGELOG.md` | @FauxxYT |
|
[`b49b3c7`](https://github.com/FauxxYT/StreamXR/commit/b49b3c79b34b8370c9761e2f219883dd65a30aa8)
| Start preparing for Live Chat integration and add icons to each popup
menu item in the Resolution popup | @FauxxYT |
|
[`dec401c`](https://github.com/FauxxYT/StreamXR/commit/dec401c2be0766ae31027756b8a3803748b66182)
| Create Live Chat panel on the right Toolbar. See Top Chat and Live
Chat | @FauxxYT |
|
[`e93b1a9`](https://github.com/FauxxYT/StreamXR/commit/e93b1a9a627dbe37e8679ed096f1326f7613b050)
| Update Design Choices document and add support for reactions in live
chat | @FauxxYT |
|
[`80934fc`](https://github.com/FauxxYT/StreamXR/commit/80934fcab4bc3eda0fcbf7754a829e859bcaef45)
| Added typing in Chat | @FauxxYT |
|
[`200a864`](https://github.com/FauxxYT/StreamXR/commit/200a8644c60952bee4fd3cc777ea781786a666a4)
| Add support for Live Chat Replays | @FauxxYT |
|
[`3b0d26d`](https://github.com/FauxxYT/StreamXR/commit/3b0d26d0823d1ab1fbc85e1598cebf46fe94d773)
| Hide/Show panels when clicking on empty space and added "Auto-Hide
While Paused" setting to enable auto-hide while paused | @FauxxYT |
|
[`8f5815d`](https://github.com/FauxxYT/StreamXR/commit/8f5815dc1a8ff5fbad3c1e7962b9ba0d0d666065)
| Update docs and give proper credit | @FauxxYT |
|
[`605972d`](https://github.com/FauxxYT/StreamXR/commit/605972dfc455943da9b598682a3d8a4d6d9046af)
| Feature 1 live chat (#2) | @FauxxYT |

<details><summary>Files changed (36)</summary>

- ✏️ `AGENTS.md` +5 −5
- ✏️ `ARCHITECTURE.md` +25 −9
- ✏️ `CHANGELOG.md` +183 −6
- ✏️ `README.md` +56 −9
- ✏️ `docs/Spatial-Layout.md` +4 −2
- ✏️ `docs/design-choices.md` +55 −2
- ✏️ `docs/features/video-page.md` +37 −10
- ✏️ `docs/hardware.md` +7 −0
- ✏️ `docs/known-issues.md` +13 −0
- ✏️ `docs/whats-left.md` +7 −2
- ✏️ `docs/youtube.md` +116 −0
- ✏️ `manifest.json` +4 −1
- ✏️ `src/core/page/controllers/icons.js` +3 −1
- ✏️ `src/core/page/math3d.js` +14 −1
- ✏️ `src/core/page/vr-panel/fade-options.js` +15 −6
- ✏️ `src/core/page/vr-panel/keyboard.js` +62 −11
- ✏️ `src/core/page/vr-panel/orbiters-ui.js` +57 −14
- ✏️ `src/core/page/vr-panel/panel-ui.js` +2 −1
- ✏️ `src/core/page/vr-panel/popups-ui.js` +33 −15
- ✏️ `src/core/page/vr-panel/safe-zone-options.js` +15 −5
- 🆕 `src/core/page/vr-panel/styles/chat.js` +226 −0
- ✏️ `src/core/page/vr-panel/styles/info.js` +175 −4
- ✏️ `src/core/page/vr-panel/styles/kit.js` +6 −4
- ✏️ `src/core/page/vr-panel/symbols.js` +9 −0
- ✏️ `src/core/page/vr-panel/vr-panel.js` +162 −55
- ✏️ `src/core/shared/strings-en.js` +4 −0
- ✏️ `src/core/shared/strings-es.js` +4 −0
- ✏️ `src/core/ui/shared/strings-en.js` +4 −0
- ✏️ `src/core/ui/shared/strings-es.js` +4 −0
- 🆕 `src/sites/youtube/page/provider/live-chat.js` +370 −0
- ✏️ `src/sites/youtube/page/provider/panels.js` +10 −3
- ✏️ `src/sites/youtube/page/provider/provider.js` +12 −5
- ✏️ `src/sites/youtube/page/provider/strings-en.js` +22 −0
- ✏️ `src/sites/youtube/page/provider/strings-es.js` +22 −0
- 🆕 `src/sites/youtube/page/youtube-chat.js` +340 −0
- ✏️ `src/streamxr/page/xr-session.js` +1 −0

</details>
<!-- pr-summary:end -->
2026-10-17 09:14:24 -07:00
Fauxx 605972dfc4 Feature 1 live chat (#2)
## What and why

Release **v0.88.1** (`Feature-1-Live-Chat` → `develop`). No issue: this
is the maintainer's release merge, not an outside contribution.

It brings everything since v0.78.9: 
- **live chat** (reading, reactions, typing with emoji pages, Top fans)
- **chat replays**
- **movable side panels**
- **hide everything with one trigger pull**
- **Auto-hide while paused**.
It also adds Quality menu icons, clearer controller icons, and fixes.
The full list is in `CHANGELOG.md` (0.78.10 to 0.88.1) and the README's
"What's new".

## How

- Live chat: `src/sites/youtube/page/youtube-chat.js` (YouTube's chat
requests) and the panel controller
`src/sites/youtube/page/provider/live-chat.js`, drawn by the new shell
style `src/core/page/vr-panel/styles/chat.js`. The shell only got
generic registry options (`available`, `autoOpen`, `stays`), no
chat-specific fields.
- Movable side panels and recenter carrying moves: `vr-panel.js`,
`orbiters-ui.js`, `math3d.js` (`invertRigid`), with the "Recenter resets
panels" switch in `safe-zone-options.js`.
- Trigger-to-hide and "Auto-hide while paused": `vr-panel.js` and
`fade-options.js`.
- Docs: README (What's new, help-test list for the live chat, Meta Quest
2 reported by u/TehSuperFruitSnack), `docs/youtube.md` (Live chat
findings), `design-choices.md`, `whats-left.md`, `known-issues.md`,
`hardware.md`.

## Tested on

- Headset and controllers: Valve Steam Frame with its controllers
- VR runtime / connection: SteamVR, streamed with "Stream VR without
game"
- Browser and version: Google Chrome <!-- version: chrome://version -->
- Signed in to YouTube: yes
- What I tried in VR: each version from 0.79.0 to 0.88.1 as it was
built. Live chat on live streams (reading, reactions, typing, Top fans,
retry while loading), chat replays (plays along, pause, seek), moving
both sides and recentering (with and without "Recenter resets panels"),
trigger-to-hide playing and paused, Auto-hide while paused on and off.

## Checklist

What the automatic summary and the Checks can't see:

- [x] There's an agreed issue for this, and this PR does only that one
thing. *(Release merge by the maintainer; no issue.)*
- [x] I understand every line of this change and can explain it in
review.
- [x] I tested it in a real VR session on the hardware listed above.
- [x] YouTube access stays in `src/sites/youtube/page/youtube-*.js`;
tunables are in the config files.
- [x] Every new file and function has its comment, and comments I
touched match the code.
- [x] New files are in `manifest.json` in the right **order** (the
summary only checks they're listed).
- [x] No new permissions, or they were agreed in the issue.

<!-- pr-summary:start -->
## Automatic summary

<!-- Filled in by .github/workflows/pr-summary.yml on every push; edits
here are replaced. -->

**FauxxYT** wants to merge `FauxxYT:Feature-1-Live-Chat` into `develop`:
8 commits, 36 files (+2084 −171).

- ⚠️ No linked issue: add `Closes #<issue>` under "What and why" (an
agreed issue is required).
- ✅ Version: 0.78.9 → 0.88.1
- ✅ `CHANGELOG.md` updated
- ✅ Every source file is listed in `manifest.json`
- ✅ `ARCHITECTURE.md` updated
- ⚠️ The shell should name no site, but added lines mention YouTube in:
`src/core/page/vr-panel/styles/info.js` (fine in comments giving an
example; otherwise move it to the provider).

#### Commits (8)

| Commit | Message | Author |
|---|---|---|
|
[`5dfddfb`](https://github.com/FauxxYT/StreamXR/commit/5dfddfb746e15b862181305495944d8ad162c598)
| Minor change to `CHANGELOG.md` | @FauxxYT |
|
[`b49b3c7`](https://github.com/FauxxYT/StreamXR/commit/b49b3c79b34b8370c9761e2f219883dd65a30aa8)
| Start preparing for Live Chat integration and add icons to each popup
menu item in the Resolution popup | @FauxxYT |
|
[`dec401c`](https://github.com/FauxxYT/StreamXR/commit/dec401c2be0766ae31027756b8a3803748b66182)
| Create Live Chat panel on the right Toolbar. See Top Chat and Live
Chat | @FauxxYT |
|
[`e93b1a9`](https://github.com/FauxxYT/StreamXR/commit/e93b1a9a627dbe37e8679ed096f1326f7613b050)
| Update Design Choices document and add support for reactions in live
chat | @FauxxYT |
|
[`80934fc`](https://github.com/FauxxYT/StreamXR/commit/80934fcab4bc3eda0fcbf7754a829e859bcaef45)
| Added typing in Chat | @FauxxYT |
|
[`200a864`](https://github.com/FauxxYT/StreamXR/commit/200a8644c60952bee4fd3cc777ea781786a666a4)
| Add support for Live Chat Replays | @FauxxYT |
|
[`3b0d26d`](https://github.com/FauxxYT/StreamXR/commit/3b0d26d0823d1ab1fbc85e1598cebf46fe94d773)
| Hide/Show panels when clicking on empty space and added "Auto-Hide
While Paused" setting to enable auto-hide while paused | @FauxxYT |
|
[`8f5815d`](https://github.com/FauxxYT/StreamXR/commit/8f5815dc1a8ff5fbad3c1e7962b9ba0d0d666065)
| Update docs and give proper credit | @FauxxYT |

<details><summary>Files changed (36)</summary>

- ✏️ `AGENTS.md` +5 −5
- ✏️ `ARCHITECTURE.md` +25 −9
- ✏️ `CHANGELOG.md` +183 −6
- ✏️ `README.md` +56 −9
- ✏️ `docs/Spatial-Layout.md` +4 −2
- ✏️ `docs/design-choices.md` +55 −2
- ✏️ `docs/features/video-page.md` +37 −10
- ✏️ `docs/hardware.md` +7 −0
- ✏️ `docs/known-issues.md` +13 −0
- ✏️ `docs/whats-left.md` +7 −2
- ✏️ `docs/youtube.md` +116 −0
- ✏️ `manifest.json` +4 −1
- ✏️ `src/core/page/controllers/icons.js` +3 −1
- ✏️ `src/core/page/math3d.js` +14 −1
- ✏️ `src/core/page/vr-panel/fade-options.js` +15 −6
- ✏️ `src/core/page/vr-panel/keyboard.js` +62 −11
- ✏️ `src/core/page/vr-panel/orbiters-ui.js` +57 −14
- ✏️ `src/core/page/vr-panel/panel-ui.js` +2 −1
- ✏️ `src/core/page/vr-panel/popups-ui.js` +33 −15
- ✏️ `src/core/page/vr-panel/safe-zone-options.js` +15 −5
- 🆕 `src/core/page/vr-panel/styles/chat.js` +226 −0
- ✏️ `src/core/page/vr-panel/styles/info.js` +175 −4
- ✏️ `src/core/page/vr-panel/styles/kit.js` +6 −4
- ✏️ `src/core/page/vr-panel/symbols.js` +9 −0
- ✏️ `src/core/page/vr-panel/vr-panel.js` +162 −55
- ✏️ `src/core/shared/strings-en.js` +4 −0
- ✏️ `src/core/shared/strings-es.js` +4 −0
- ✏️ `src/core/ui/shared/strings-en.js` +4 −0
- ✏️ `src/core/ui/shared/strings-es.js` +4 −0
- 🆕 `src/sites/youtube/page/provider/live-chat.js` +370 −0
- ✏️ `src/sites/youtube/page/provider/panels.js` +10 −3
- ✏️ `src/sites/youtube/page/provider/provider.js` +12 −5
- ✏️ `src/sites/youtube/page/provider/strings-en.js` +22 −0
- ✏️ `src/sites/youtube/page/provider/strings-es.js` +22 −0
- 🆕 `src/sites/youtube/page/youtube-chat.js` +340 −0
- ✏️ `src/streamxr/page/xr-session.js` +1 −0

</details>
<!-- pr-summary:end -->
2026-10-17 09:07:37 -07:00
Fauxx 8f5815dc1a Update docs and give proper credit 2026-10-17 08:50:46 -07:00
Fauxx 3b0d26d082 Hide/Show panels when clicking on empty space and added "Auto-Hide While Paused" setting to enable auto-hide while paused 2026-10-17 08:42:59 -07:00
Fauxx 200a8644c6 Add support for Live Chat Replays 2026-10-17 08:28:52 -07:00
Fauxx 80934fcab4 Added typing in Chat 2026-10-16 17:40:14 -07:00
Fauxx e93b1a9a62 Update Design Choices document and add support for reactions in live chat 2026-10-16 10:29:24 -07:00
Fauxx dec401c2be Create Live Chat panel on the right Toolbar. See Top Chat and Live Chat 2026-10-16 09:53:07 -07:00
Fauxx b49b3c79b3 Start preparing for Live Chat integration and add icons to each popup menu item in the Resolution popup 2026-10-16 09:06:34 -07:00
Fauxx 5dfddfb746 Minor change to CHANGELOG.md 2026-10-16 08:28:04 -07:00
36 changed files with 2084 additions and 171 deletions

No files matched your search

+5 -5
View File
@@ -109,12 +109,12 @@ for the code rules.
`streamxr-*`, storage `streamxr.*` (storage.js moves old `streamvr.*` / `ytvr.*` values over).
- No ES modules: each file is a plain script that adds to `globalThis.__streamxr`, and
`manifest.json` holds the load order.
- YouTube's panels' ids are `desc`, `comments`, `channel` (left of the Safe Zone), `recs`,
`browse` (Explore), `you` (right), `search` (the search bar over the zone, its results in
the middle) and `settings` (the pill's popup); the open side panels are remembered as
`streamxr.leftPanel` / `streamxr.rightPanel`. Their controllers are in
- YouTube's panels' ids are `desc`, `comments`, `channel` (left of the Safe Zone), `chat`
(live streams only), `recs`, `browse` (Explore), `you` (right), `search` (the search bar over
the zone, its results in the middle) and `settings` (the pill's popup); the open side panels
are remembered as `streamxr.leftPanel` / `streamxr.rightPanel`. Their controllers are in
`src/sites/youtube/page/provider/` (`description.js`, `comments-panel.js` (+ `comments.js`),
`channel.js`, `search.js`, `browse.js` (Explore and You), `upnext.js`, `settings.js`),
`channel.js`, `search.js`, `browse.js` (Explore and You), `upnext.js`, `settings.js`, `live-chat.js`),
registered in `panels.js`; the controls panel's Captions and Save buttons are `extras.js`.
- New YouTube behavior goes in the provider or a panel controller; a style or the shell
never gets a YouTube field (add a generic option or slot instead).
+25 -9
View File
@@ -104,13 +104,14 @@ src/core/ THE CORE (shell, part 1): the VR interface, site-
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
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
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
@@ -151,13 +152,14 @@ src/sites/youtube/ YOUTUBE
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 (the shell's "extras")
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
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/
@@ -271,15 +273,29 @@ A site lists its panels (YouTube: `provider/panels.js`, which documents the inte
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 }
```
- `host`: `{ actions, style, perPage, open(), close(), isOpen(), closeMenu(), confirm(d), typeInto(on) }`;
`confirm(d)` asks in the shell's dialog (`{ title, text, confirm, cancel, danger }` -> a Promise
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(), 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`.
- Typing: a panel with a text field calls `host.typeInto(true)` to get the keys (and
`false` to give them back). While it's shown, each key reaches its controller as
`{ type: 'key', value }`: a character, `Backspace`, `Enter` or `Escape`. The keys come
`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
@@ -300,7 +316,7 @@ control's id there is `<panel id>|<style id>`.
| Style | YouTube panel | Draws |
|---|---|---|
| **Info** (`info.js`) | Description | 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), `message` (optional loading indicator) |
| **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), 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) |
@@ -312,7 +328,7 @@ Info and Profile add `scrollAt`, `maxScroll`, `shownScroll`, `pxPerV`, Options a
**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`, `switch`, `slider`, `default`,
`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:
+183 -6
View File
@@ -6,13 +6,186 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions are
`manifest.json` version. All of these were developed in October 2026. Technical notes for
developers are in [ARCHITECTURE.md](ARCHITECTURE.md) and [docs/](docs/).
## Unreleased
## 0.88.1
### Documentation
- The README's "as soon as it's possible" roadmap and "What you need" table: **native Steam
Frame support**, StreamXR running in the Frame's own Chromium browser (no PC) once that
browser supports WebXR. Also in `docs/hardware.md` and `docs/whats-left.md`.
- The README's links to "What about Netflix, MAX and the others?" work again.
### Changed
- The Auto-hide tab's new switch is called **"Auto-hide while paused"** (was "Hide while paused"), so
it says what it does.
## 0.88.0
### Added
- **Pull the trigger on empty space to hide everything at once** (suggested by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)): the controls panel, the side
toolbars, the search bar and the lasers go away without waiting for auto-hide (paused too).
A press that closes a menu, a dialog or typing still only does that; the next one hides.
Another pull, or pointing at a panel, brings them back. Panels set to stay up (the live
chat) stay.
- **Settings → Auto-hide → "Hide while paused"** (suggested by u/TehSuperFruitSnack; off by default, remembered): while on, the
auto-hide waits also run with the video paused, so you can pause and take in a 180° / 360°
scene with nothing in the way. The waits themselves are unchanged.
## 0.87.1
### Fixed
- Opening a video whose chat YouTube's page hasn't loaded yet (e.g. going from a stream without a
chat replay to one with) showed "Couldn't load the chat" until you switched tabs. The chat now
shows the loading indicator and tries again on its own (every 3 s, up to five times) before
giving up.
## 0.87.0
### Added
- **Chat replays:** on a finished live stream that kept its chat, the Live chat panel plays the
recorded chat along with the video (tabs: YouTube's "Top chat replay" / "Live chat replay").
Each message appears when the video reaches the moment it was sent, so pausing stops the
chat, and jumping back or ahead starts it again at the new place with the last minute of
chat shown at once. It opens by itself as the live chat does. No typing, reactions or Top
fans in replays, as on YouTube.
## 0.86.0
### Added
- **Settings → Safe zone → "Recenter resets panels"** (off by default, remembered): while on,
recentering also puts the panels you moved back in their places, as before 0.84.0. The global
reset turns it off.
### Changed
- The left D-pad's icon (show / hide the panels) is Material Symbols' **Bottom Panel Close**
while the panels are up and **Bottom Panel Open** while they're hidden (was a single "call to
action" icon).
## 0.85.0
### Changed
- **The side panels' move handle is a grab bar** beside each side's toolbar, on the video side:
an upright pill with a drag handle, like the keyboard's and the controls panel's bars (so
everything you can move is grabbed the same way), with a laser zone wider than it looks.
Grip it to move the whole side. It replaces the Move button at the toolbar's end; the
toolbars stand a little further out to make room.
## 0.84.2
### Changed
- **The grip's icon shows a hand** (Material Symbols' Back Hand) instead of recenter whenever the
grip would grab rather than recenter: pointing at (or holding) a move bar or the side
toolbars' Move handle, the keyboard's bar or resize corners, or the cinema screen's bar and
corners.
## 0.84.1
### Changed
- **The side panels' move handle is on their toolbar** now: a **Move** button (four arrows) at
the end of each side's toolbar, after a small divider; pointing at it grows its name like the
others, and gripping it moves the whole side (the point you hold follows your hand). It
replaces the bar under the panel, which was hard to find.
## 0.84.0
### Changed
- **Recenter keeps what you moved:** recentering turns the layout to where you're looking and
brings your moved panels along, each where you put it relative to you: the controls panel
(with the keyboard and Settings) and any side moved by its grab bar. It used to put them all
back. Changing the Safe Zone or the panel size keeps them too; only the global reset (right
stick held 3 s) puts them back in their places.
## 0.83.1
### Fixed
- Pointing at the **live chat while the other panels are hidden** (it stays up while you watch):
the stick showed its seek icon instead of the scroll icon, and pushing it scrolled the chat
*and* jumped the video ±10 s. The stick now belongs to a panel that stays, as to any shown
panel: scroll icon, scrolling only.
## 0.83.0
### Added
- **Move the side panels:** a grab bar under each side's open panel (left and right). Grip it
and move: the panel follows your hand and turns to face you, and its whole side comes along
(the toolbar, and every other panel on that side opens there too). Recenter, a Safe Zone
change or a new panel size puts the sides back. (`vr-panel.js` keeps a move per side;
`orbiters-ui.js` `createGrabBar`; `math3d.js` `invertRigid`.)
## 0.82.1
### Fixed
- **Two text cursors:** clicking the search bar while typing in the live chat (or the other way
round) left a cursor in the field you'd left, and a click on nothing then only cleared one.
Now the field you leave stops typing as soon as another one starts (the shell sends it
Escape), and the keyboard stays up through the switch.
## 0.82.0
### Added
- **Top fans** in the live chat: your **XP** in a 👑 pill at the right end of the chat's tabs
(kept up to date as you chat); clicking it opens the **Top fans** list in the panel: rank,
picture, name and XP, the top three with their badges, and your own row at the bottom with
YouTube's line about it (refreshed every 30 seconds while open; ‹ goes back to the chat). The
top three's **"#1" / "#2" / "#3" pills** now show before their names in the chat.
## 0.81.1
### Fixed
- The **Support on desktop** dialog cut its message off ("…") before the end: dialogs now
show up to four lines of text, the card growing to fit (short ones, like "Unsubscribe?",
stay as they were).
## 0.81.0
### Added
- **Typing in the live chat:** a message field with a **Send** button above the reaction
buttons. Clicking the field brings up the VR keyboard; Enter or Send posts the message (it
then appears in the chat), Escape or a click on nothing stops typing. YouTube's limit is kept
(200 characters, an emoji counting as 10). Where you can't chat, YouTube's reason shows
instead of the field.
- **Emojis on the VR keyboard:** a 😀 key opens pages of emojis (‹ › between pages), with the
panel's own first: in the live chat, the channel's member emojis, shown as pictures in the
field and sent as emojis.
- **Support on desktop** (live chat): a button beside the reactions opening a dialog with what
the stream offers (Super Chat, gifts, memberships…) and where to pay for it: YouTube's own
window, with the $ button under the chat.
- For the shell: `typeInto(on, { emojis })`, the dialog's text on two lines and `cancel: null`
(an information dialog), the Info style's chat `input` and `support`.
## 0.80.0
### Added
- **Live chat reactions:** the stream's reaction buttons (❤ 😄 🎉) along the bottom of the Live
chat panel; clicking one sends it to YouTube (at most one every half second) and lights the
button a moment. Shown only when the stream offers reactions (signed in). Other viewers'
floating reactions aren't shown yet. (Info style's `chat` content: `reactions`.)
## 0.79.1
### Fixed
- **Live chat** said "Couldn't load the chat" on both tabs: the watch page's view tokens load an
empty chat page. The views' tokens now come from the chat frame beside the stream (the ones
YouTube itself uses), or, when the page shows no chat, from the pop-out chat page.
## 0.79.0
### Added
- **Live chat** (reading): on a live stream, a **Live chat** panel opens on the right (its
button, at the top of the right toolbar, shows only on live streams). It **stays up while
you watch** when everything else fades. Tabs for YouTube's **Top chat** and **Live chat**
views (the one you pick is remembered); new messages every 2 seconds. Messages show the
author's picture and name (the owner on a yellow chip, moderators in blue with a wrench,
members in green with their badge), custom and standard emojis inline, **Super Chats**,
**Super Stickers** and **memberships** as coloured cards, YouTube's welcome notice, and the
**pinned message** in a banner at the top. Scrolling back pauses the list ("Chat paused", as on
YouTube) and a button shows how many new messages are waiting; it, or scrolling to the end,
goes back to the newest. Deleted messages disappear. YouTube's AI "Chat summary" banner is
left out. Not yet: chat replays (after a stream), reactions, typing.
- For other sites' panels (generic registry options, ARCHITECTURE.md): `available` (a side
panel offered only some of the time: its toolbar button hides meanwhile), `autoOpen` (opens
whenever it becomes available, the panel it replaced coming back after) and `stays` (doesn't
fade with the rest); the Info style's `chat` content (`styles/chat.js`).
## 0.78.10
### Changed
- The **Quality menu** shows each quality's icons on the left (4K, 2K, Full HD, HD, below-HD,
plus 60 fps where it applies), the same icons as the Quality button; the tick moves to the
right end. (A generic menu option, `icons` on an item, popups-ui.js.)
## 0.78.9
@@ -26,6 +199,10 @@ developers are in [ARCHITECTURE.md](ARCHITECTURE.md) and [docs/](docs/).
**Streaming Status** → your PC → **Advanced** → **Stream VR without game**, then open Chrome.
Also in the troubleshooting table, the "What you need" table, the question issue form and
`docs/hardware.md`.
- The README's "as soon as it's possible" roadmap and "What you need" table: **native Steam
Frame support**, StreamXR running in the Frame's own Chromium browser (no PC) once that
browser supports WebXR. Also in `docs/hardware.md` and `docs/whats-left.md`.
- The README's links to "What about Netflix, MAX and the others?" work again.
## 0.78.8
+56 -9
View File
@@ -4,18 +4,47 @@ experience: 360° and VR180 videos wrap around you, regular videos play on a gia
screen, and you can browse, search, subscribe, like, save and read comments without ever
taking your headset off. 🎬✨
**Works with:**<br> 🖥️ Windows · 🌐 Chrome · 🎮 SteamVR · 🥽 Valve Steam Frame · ([full requirements](#-what-you-need))<br>
**Works with:**<br> 🖥️ Windows · 🌐 Chrome · 🎮 SteamVR · 🥽 Valve Steam Frame · 🥽 Meta Quest 2 (reported by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)) · ([full requirements](#-what-you-need))<br>
**Jump to:** [Get started](#-get-started-in-a-minute) ·
[Quick troubleshooting](#-quick-troubleshooting) · [Controls](#-controls) ·
[Your privacy](#-your-privacy) · [Roadmap](#-roadmap) · [How you can help](#-how-you-can-help)
[Your privacy](#-your-privacy) · [What's new](#-whats-new-in-v0881) · [Roadmap](#-roadmap) · [How you can help](#-how-you-can-help)
> **Early access** (version 0.78). Free and open source, forever. It already works every day; it's
> **Early access** (version 0.88). Free and open source, forever. It already works every day; it's
> still growing fast, and your feedback shapes what comes next.
>
> StreamXR is vibe coded by one guy (me!), and the only headset I've got is the Steam Frame.
> Bug fixes, updates and support for more headsets and sites are coming, so please be
> patient with me. 😭🙏
## ✨ What's new in v0.88.1
The latest release, in short (every version's details are in the [changelog](CHANGELOG.md)):
- 💬 **Live chat:** on a live stream, the chat opens on the right and stays up while you watch.
Read it with emojis, badges, Super Chats and the pinned message, tap the stream's
reactions (❤ 😄 🎉), and type messages on the VR keyboard, which now has emoji pages
(with your channel's member emojis). A **Support on desktop** button points you to YouTube's
window for Super Chats and memberships.
- 🏆 **Top fans:** your **XP** in the chat, the ranked list of the stream's top fans, and
the top three's #1 / #2 / #3 pills.
- ⏪ **Chat replays:** on a finished stream that kept its chat, the chat plays along with the
video. It pauses when you pause and follows when you jump back or ahead.
- ✋ **Move the side panels:** grip the grab bar beside either side's toolbar and put that side
where you like. Recentering keeps it there, unless you turn on Settings → Safe zone →
**Recenter resets panels**.
- 👆 **Hide everything in one pull** (suggested by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)): pull the trigger on empty space and the panels, toolbars
and search bar go away at once. Pull again to bring them back.
- ⏸️ **Settings → Auto-hide → Auto-hide while paused** (suggested by u/TehSuperFruitSnack too): pause and soak in a 180° / 360° scene
with nothing in the way (off by default).
- 🎚️ **Quality menu icons:** each quality shows its 4K / Full HD / HD (and 60 fps) icons, as the
Quality button does.
- 🖐️ **Clearer controller icons:** the grip shows a hand when it will grab something, and the
left D-pad shows a panel icon for show / hide.
- 🥽 **Meta Quest 2 reported working:** [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack) watches through SteamVR on a Quest 2, 8K videos included. Thank you! Other headsets: [tell me how yours does](#-adding-a-headset).
- 🩹 **Fixes:** the chat now tries again on its own when YouTube's page hasn't loaded it yet;
typing in the chat and the search bar no longer shows two text cursors; long dialog
messages are no longer cut off.
## 💡 Why I made this
Two reasons:
@@ -113,6 +142,11 @@ glassy toolbars on each side:
- ➡️ **What to watch:** **Up Next**, **Explore** (your Home feed with all its topic chips,
"Immersive videos" included, plus News) and **You** (Subscriptions, Your channel,
History, Playlists, Watch later, Liked videos).
- 💬 **Live chat** on live streams (and **chat replays** on finished ones, playing along with the
video): it opens by itself and stays up while you watch. Read it with emojis, badges, Super
Chats and the pinned message; send reactions and type messages on the VR keyboard (with your
channel's member emojis); see your **XP** and the **Top fans**. Every side can be moved
by the grab bar beside its toolbar.
### 🎨 Make it yours (Settings)
- Looks: **YouTube** or **Material 3** colors, **glass** panels with an adjustable tint, the
@@ -177,7 +211,7 @@ The full design story is in [docs/Materials-Redesign.md](docs/Materials-Redesign
|---|---|---|
| 🖥️ **Computer** | **Windows 10 / 11** | Tested on Windows 11. macOS and Linux aren't supported (Chrome's WebXR needs Windows + SteamVR). |
| 🌐 **Browser** | **Google Chrome** | Tested. Other Chromium browsers (Edge, Brave) should work but haven't been tried. Firefox can't yet: desktop Firefox has no WebXR (a 🦊 Firefox version will come once it does). |
| 🥽 **Headset** | **Valve Steam Frame** | Tested and tuned for it, controller models and all, streamed from your PC. (Running in the Frame's own browser, no PC needed, comes once it supports WebXR: see the [roadmap](#-roadmap).) Other SteamVR headsets may well play video, but they're untested and their buttons may be mapped differently ([help add yours](#-adding-a-headset)). |
| 🥽 **Headset** | **Valve Steam Frame**<br>**Meta Quest 2** (reported by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)) | **Steam Frame:** tested and tuned for it, controller models and all, streamed from your PC. (Running in the Frame's own browser, no PC needed, comes once it supports WebXR: see the [roadmap](#-roadmap).)<br>**Quest 2:** reported working through SteamVR, 8K videos included. Its controllers show as Steam Frame models.<br>Other SteamVR headsets may well work too; their buttons may be mapped differently ([help add yours](#-adding-a-headset)). |
| 🎮 **VR software** | **SteamVR** | Running **before** you open Chrome, and set as your OpenXR runtime. On the Steam Frame, stream it with **Stream VR without game** ([how](#-get-started-in-a-minute)). |
| 📺 **Site** | **YouTube** | Signed in or out. With YouTube Premium, even better (no ads). |
| 🧩 **Optional** | **Skip Helper** | A tiny second extension that lets you skip ads from inside VR ([why it's separate](#-skipping-ads)). |
@@ -227,11 +261,11 @@ Still stuck? See [Something not working?](#-something-not-working) for how to re
| Input | Does |
|---|---|
| Trigger | click the panels with the laser; hold on the seek bar to scrub; drag a scrolling area; brings the panels (and lasers) back when they're hidden |
| Trigger | click the panels with the laser; hold on the seek bar to scrub; drag a scrolling area; on empty space, hides the panels, toolbars and search bar at once (another pull brings them back, with the lasers) |
| A (right) | play / pause |
| B (right) | swap the controls panel for the keyboard (same place), and back: click its keys with the laser to type into a text box (Search); the bar under it moves it (and the controls panel with it), its bottom corners resize it |
| Left D-pad | show / hide the panels |
| Grip | recenter, or grab: the bar under the panel moves it; on the cinema screen the corner arcs resize it and the bar under it moves it (remembered) |
| Grip | recenter, or grab (its icon turns into a hand when it will grab): the bar under the panel moves it (the bar beside a side's toolbar moves that whole side; recentering keeps what you moved, unless you turn on Settings → Safe zone → "Recenter resets panels", and holding the right stick for 3 s puts it all back); on the cinema screen the corner arcs resize it and the bar under it moves it (remembered) |
| Stick left / right | ±10 s. On a panel with page arrows: previous / next page (the stick's icon changes to ◀▯▶) |
| Stick up / down | on a scrolling area (Description, Transcript, Comments, a long menu): scroll (icon ▲≡▼) |
| Right stick click, held 3 s | reset all settings to their defaults and recenter (the stick's icon counts down 3-2-1 with a ring; let go to cancel) |
@@ -286,7 +320,8 @@ what bugs you most):
- **No controller vibration.** Chrome doesn't give the Steam Frame controllers a way to
vibrate yet, so Settings → Appearance → Vibration is greyed out.
- **Only Steam Frame controller models.** Other controllers are drawn as Steam Frame
controllers, and other headsets are untested. **Got 3D models of your controllers?** 🧊
controllers. Besides the Steam Frame, only the Quest 2 has been reported working (by
u/TehSuperFruitSnack); other headsets are untested. **Got 3D models of your controllers?** 🧊
Share them in a **Headset / controller compatibility** issue (with where they came from and
their license) and I'll integrate them once I start accepting pull requests. They need a
license that lets them be shared and changed in a free project, with credit (for example
@@ -433,6 +468,17 @@ You don't have to write code to make StreamXR better:
- 🐞 **Report bugs:** on GitHub, go to **Issues → New issue**, pick the form that fits, and paste
the **Copy details** report from the VR button's popup.
- 🧪 **Help test the live chat:** these parts are built from what YouTube is known to send, but I
haven't caught them on a real stream yet. If you see one in VR, tell me whether it looked
right (a **Bug report** if it didn't, or a comment on the **Live chat: help test** issue if it did):
- 💰 **Super Chats** and **Super Stickers** (colored cards with the amount)
- 🎉 **New members** and **gifted memberships** (green cards)
- 🛡️ The **channel owner's** name on a yellow chip, and **moderators** in blue with a wrench
- 🗑️ A **deleted message** disappearing, and a **pinned message** in the banner at the top
- 😺 **Your channel's member emojis** inside a message you send
- 🚫 A chat you **can't type in** (members only, slow mode...): YouTube's reason should show
instead of the message field
- 📺 An **ad** during a chat replay: the chat should carry on where it was
- 🥽 **Try it on your headset:** if you have a headset other than the Steam Frame, tell me
what works and what doesn't. The **Details** report (press every controller button first)
is often all I need to support it ([how](#-adding-a-headset)).
@@ -500,8 +546,9 @@ order by `manifest.json` (one vendored library, Google's Material Color Utilitie
## 🥽 Adding a headset
The extension is built and tested on the Steam Frame only, because that's the only headset I
have. Help for other headsets is very welcome; this section is the map. Open an
The extension is built and tested on the Steam Frame, because that's the only headset I
have; [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack) has reported the **Meta Quest 2** working through SteamVR too. Help for other
headsets is very welcome; this section is the map. Open an
issue before writing code (see [CONTRIBUTING.md](CONTRIBUTING.md)).
**What actually differs between headsets.** Almost nothing in the video side: WebXR hands
+4 -2
View File
@@ -288,8 +288,10 @@ your eyes were at the last recenter, each piece turned to face you:
- **Sizes:** every canvas at `layout.pxPerDegree` (26) px per degree, so text looks as big
as before; side panels 900 × 1100 px (about 35° × 43°); the grids keep today's card size
and fill the height (3 × 3).
- Changing the Safe Zone (or recentering) lays everything out again, which also brings back
a controls panel you moved.
- Changing the Safe Zone (or recentering) lays everything out again. What you moved by a grab
bar (the controls panel, a side) keeps its move: a recenter turns and shifts it with the
layout, so it stays where it was relative to you (0.84.0; it used to come back). The global
reset puts it back.
- **0.65.0:** toolbars ordered top to bottom: Description, Comments, Channel (left);
Search, Up Next, Browse (right); Browse's icon is Material Symbols' "browse". The
toolbars are solid while their side has a panel open (glass when it has none, 0.65.2: the panel's edge
+55 -2
View File
@@ -57,6 +57,50 @@ even where they look like something to "fix".
covers the text.
- The Previous / Next hover card sits 32 dp (about 4.7 cm) in front of and 2 cm above its
button, in the controls panel's tilt.
- **Live Chat (planned; the maintainer, 2026-10-16):** a **right-side panel**, where YouTube
and Twitch put chat, opened from a Chat button on the right toolbar that shows **only on
live streams** (and chat replays). Like the other side panels it shares the right zone (one
open at a time), but it **doesn't fade**: its toolbar still fades, the chat stays up while
you watch. Built in phases: **reading** first (messages, badges, Super Chats / Stickers,
memberships, the pinned message, member emojis), then **reactions** (a row of reaction
buttons on the panel), then **typing** with the existing VR keyboard (no second keyboard),
which gains an **emoji layer** (standard emojis and the channel's member emojis).
Paying (Super Chat, memberships) stays out of VR: a **"Support on desktop"** button opens
YouTube's own screen in the browser window. YouTube's AI **"Chat summary"** banner is left
out; pinned messages and the welcome notice are shown (the maintainer, 2026-10-16).
**Reading built in 0.79.0** (working since 0.79.1): the chat **opens by itself** when a live
stream starts, as on YouTube, the panel it replaced coming back when the stream ends
(confirmed by the maintainer, 2026-10-16); tabs for YouTube's Top chat / Live chat views (the
choice remembered). **Typing built in 0.81.0**: a message field and Send above the reactions;
the keyboard's 😀 key opens its emoji pages (the channel's own emojis first). **Support on
desktop** is a dialog (the maintainer, 2026-10-16): YouTube's "$" screen can't be opened
from StreamXR (no button to click in its chat frame), so the button says what the stream
offers and to use the $ button under the chat in YouTube's own window. **Top fans (0.82.0,
as YouTube's chat does it, to check)**: your XP in a 👑 pill at the right end of the tab
row; it opens the ranked list inside the chat panel (‹ back to the chat; your row pinned at
the bottom); the top three's rank pills ("#1", in YouTube's colours) before their names.
**While only the chat stays up** (everything else faded), a laser shows while it points at
the chat and hides again when it leaves it, so you can tell exactly when you're on the chat
(the maintainer, 2026-10-17: keep it so).
- **Every side panel can be moved (the maintainer, 2026-10-16; built in 0.83.0):** the left and
right panels all can, not only the chat. Moving a panel moves its **whole side**: the toolbar
and every panel on that side follow (they open where you put them). Moving only; no
resizing (the Panel size setting scales everything). **The handle is a grab bar beside the
side's toolbar, on its video side** (the maintainer, 2026-10-16; 0.85.0): an upright pill
with a drag handle, the same as the keyboard's and the controls panel's bars, so everything
movable is grabbed the same way (a button there, tried in 0.84.1, invited a trigger click
though it only works with the grip; a bar under the panel, 0.83.0, was hard to find). Its
laser zone is wider than the pill. It works with the grip (whose icon turns into a hand
there): the point you hold follows your hand and the side turns to face you; the toolbar
stands a little further out to make room for it.
- **Recenter keeps what you moved (the maintainer, 2026-10-16; 0.84.0; replaces "Recenter or a
Safe Zone change puts it back"):** recentering turns the layout to where you're looking and
brings your moved panels along, each where you put it relative to you (the controls panel,
with the keyboard and Settings; each moved side). A Safe Zone or panel size change keeps them
too. Only the global reset (the right stick held for 3 s) puts them back in their places.
**A setting for the other way (the maintainer, 2026-10-16; 0.86.0):** Settings → Safe zone →
"Recenter resets panels" (off by default, remembered; the global reset turns it off):
while on, recentering also puts the moved panels back.
## Look
@@ -159,8 +203,10 @@ even where they look like something to "fix".
morphing shape on a dark disc, Material 3 Expressive; 0.56.0) shows in the same spot.
- **Controller icons (0.69.0):** Material 3 Expressive round tonal icon buttons (the colour
theme's button colour: white in YouTube, pink in Material 3) with Material Symbols:
play_pause (A), keyboard (B; keyboard_hide while the keyboard is up), ads_click (trigger), call_to_action (left D-pad: the
panels), recenter (grip), replay_10 + forward_10 (stick; swap_vert while it scrolls,
play_pause (A), keyboard (B; keyboard_hide while the keyboard is up), ads_click (trigger),
bottom_panel_close / bottom_panel_open (left D-pad: hide the panels while they're up, show them
while they're hidden; call_to_action until 0.86.0), recenter (grip; back_hand while it would
grab a move bar or handle, 0.84.2), replay_10 + forward_10 (stick; swap_vert while it scrolls,
menu_book while it turns pages). A held button's countdown: an M3E circular progress
ring in the theme's red round the same button, the seconds left, then a tick.
- **The trigger wakes everything (0.69.0):** with the panels hidden (auto-hide or the left
@@ -177,6 +223,13 @@ even where they look like something to "fix".
panels since 0.78.4), the search
bar (3 s), the controllers and the lasers (3.5 s each, when held still). Each from 1 s to
30 s or Never, with a Default button; remembered; the global reset puts them back.
- **A trigger pull on empty space hides everything (0.88.0, the maintainer, 2026-10-17, from a
user's request):** the controls panel, the side toolbars, the search bar and the lasers go at
once, paused or not (a press that lets go of a menu, a dialog or typing does only that).
Panels that stay up (the live chat) stay. A pull or pointing at a panel brings them back.
- **"Auto-hide while paused" is a switch (0.88.0, the maintainer, 2026-10-17):** Settings → Auto-hide,
off by default (paused, everything stays up, as before); on, the auto-hide waits run while
paused too, to take in a scene. The default waits stay as they are.
- Live streams count as "live" until you're 12 s behind, so a short pause stays red.
- Unsubscribing asks first, in a dialog (0.67.0): "Unsubscribe from <channel>?" with Cancel
and Unsubscribe (dark red), on the panels' glass (0.67.1); it floats in front of everything (56 dp) with the rest dimmed
+37 -10
View File
@@ -84,18 +84,34 @@ published; what matters from them is in this file and docs/youtube.md).
- v0.30.1: the creator's ❤ shows; like / dislike buttons (confirmed signed in).
- Not yet: replies to replies (YouTube's newer nested threads), writing comments
(writing needs the VR keyboard).
- [ ] **Live chat** (medium to large, read-only first)
- Code: none yet. YouTube's chat polls `live_chat/get_live_chat` with a continuation token
(from the watch page's live chat data) and says how long to wait before the next poll;
a finished stream's chat replay uses `live_chat_replay/get_live_chat_replay`, each
message tagged with its video time. Both need probing live before writing the reader.
- [ ] **Live chat** (medium to large, read-only first): **phase 1 (reading) built in 0.79.0**,
working in the headset since 0.79.1 (checked by the maintainer).
- Code: `youtube-chat.js` (the views, the chat page, polling every 2 s; docs/youtube.md,
"Live chat") and `provider/live-chat.js` (the panel; Info style's `chat` content). A
finished stream's **chat replay** plays along with the video since 0.87.0 (to check):
fetched ahead with `live_chat/get_live_chat_replay`, each message shown at its time in the
video, following pauses and seeks.
- Messages: text with emoji images, author with badges (moderator, member, verified),
Super Chats / Stickers (coloured, with the amount), membership messages.
- VR idea: a tall narrow panel standing beside the cinema screen (or to the side in
180°/360°), not in the chevron stack, so you can watch and read at once. Newest at the
bottom, auto-scrolling, with "jump to newest" after scrolling back. A switch to hide it;
only shown on live streams and chat replays (replay follows the video time and seeks).
- Sending messages needs the VR keyboard (see Search), so it comes later.
- Decided (design-choices.md, 2026-10-16): a **right-side panel** from a Chat button on the
right toolbar, shown only on live streams and chat replays; it **doesn't fade** with its
toolbar (a generic registry option, no chat field in the shell). Newest at the bottom,
auto-scrolling, with "jump to newest" after scrolling back; a replay follows the video
time and seeks.
- Phases: 1. **reading** (messages, badges, Super Chats / Stickers, memberships, the pinned
message as a banner, member emojis inline: 0.79.0); 2. **reactions** (a row of reaction
buttons along the panel's bottom, sent with `youtubeChat.react()`: 0.80.0, working in the
headset; other viewers' floating reactions deferred, see "Deferred"); 3. **typing** with the existing VR keyboard, which
gains an **emoji layer** (standard emojis plus the set the panel hands it: the channel's
member emojis), and a **"Support on desktop"** button (paying stays out of VR): built in
0.81.0, to check (a message field with Send; the keyboard's 😀 pages; Support opens a dialog
pointing to YouTube's own window). Then **Top fans** (0.82.0, to check): your XP in a 👑
pill at the right of the chat's tabs, opening the ranked list in the panel (your row at
the bottom), and the top three's "#1" / "#2" / "#3" pills in the chat.
- Then **every side panel movable** (left and right): a grab bar beside each side's toolbar,
on the video side (0.85.0; a bar under the panel in 0.83.0, a toolbar button in 0.84.1). Moving one moves its whole side (toolbar and panels);
No resizing. Built in 0.83.0 (works in the headset); since 0.84.0 a recenter keeps the moves
(only the global reset puts them back).
- [x] ~~**Search**~~ DONE (v0.42.0: a "Search" chevron between Channel and Browse, Catalog
style with a search field; v0.43.0: the in-VR keyboard, right controller's A)
- ~~Code: `youtubeContent.search(query)`~~ (ads and the Shorts shelf are skipped)
@@ -106,3 +122,14 @@ published; what matters from them is in this file and docs/youtube.md).
- **Technical info**: `youtube.statsForNerds()` (codecs, resolution, bandwidth, buffer),
`youtube.videoData()`, `youtube.loadedFraction()`. These could go in the VR Details panel.
- **YouTube's own 360° view angle**: `youtube.sphericalView()`, `youtube.setSphericalView()`
## Deferred
Left out for now by the maintainer; each can come back if there is demand for it.
- **Other viewers' floating reactions in the live chat** (medium; the maintainer, 2026-10-17)
- What: the emojis that float up over YouTube's chat when viewers press the reaction
buttons.
- Why: they arrive as `emojiFountainDataEntity` entity updates (seen once, in probe 9)
whose shape is unknown, and there's no stream known to send many of them to build
and test against. Sending your own reactions works (0.80.0).
+7
View File
@@ -42,6 +42,13 @@ Steam Frame with SteamVR, desktop Chrome). For supporting other hardware, see th
Vibration switch (Settings → Appearance) is greyed out with a note until a controller offers one, so it
works by itself if a later Chrome adds it.
## Other headsets (reported by users)
- **Meta Quest 2** (reported by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack) on Reddit, after 0.88.1): works through SteamVR in desktop Chrome,
8K videos included. Not tried by the maintainer, and not reported yet: how its buttons map
(Copy details' `controllers` part would show it) and how it connects to the PC (Link,
Air Link or another streaming app). Its controllers are drawn as Steam Frame models.
## Controller models
- `src/core/page/controllers/models.js` is generated from Valve's Steam Frame controller CAD
+13
View File
@@ -8,6 +8,19 @@ permissions, Firefox) are in [ARCHITECTURE.md](../ARCHITECTURE.md#known-limits).
- **Signed-in Home feeds** other than the main one, History, Playlists, Watch later and
Liked videos (those were verified in 0.35) (`youtube-home.js`).
- **Live chat typing (0.81.0)**: sending a message was checked from the console (text only);
in the headset, check typing, Send / Enter, a channel emoji in a message (sent by its id, as
YouTube's client does), and a chat you can't type in (YouTube's reason should show instead
of the field: that shape wasn't seen yet).
- **Live chat (0.79.0)**: written from probes of real chats (text messages, the welcome notice,
emojis, banners) and, for what didn't show up during the probes, from the shapes YouTube
is known to send: **Super Chats, Super Stickers, memberships and gifted memberships, the
owner / moderator / member badges, deletions, pinned messages** (`youtube-chat.js`). Check
them on a busy stream. (The README's "How you can help" asks users to report them.)
- **Chat replays (0.87.0)**: work in the headset (plays along, pauses, seeks). Untried: an ad
during a replay (its time could count as a seek and reload the chat). With YouTube's chat
hidden the replay is still found (probe 11); if its chat frame were ever missing, there's no
pop-out replay page to fall back on (HTTP 400), so it would end in "Couldn't load the chat".
## Known quirks
+7 -2
View File
@@ -2,7 +2,7 @@
One list of what's still open, gathered from the feature backlogs (`docs/features/`) and
`docs/known-issues.md`, which hold the details. Tick or remove items as they're done.
Last updated for 0.78.9.
Last updated for 0.88.1.
## Top priority: before the first release
@@ -80,7 +80,12 @@ Netflix first) once they allow protected video in desktop XR.
- [ ] **News**: its own tabs (Top stories, Sports ...) and the Sports feed.
- [ ] **Subscriptions**: shelves and posts in the feed, the list of your channels, the bell per
channel (`features/subscriptions-page.md`).
- [ ] **End screen**, **Info cards**, **Live chat** (read-only first) (`features/video-page.md`).
- [ ] **Live chat**: reading (0.79.1) and reactions (0.80.0) work in the headset; typing, the
keyboard's emoji pages and Support on desktop built in 0.81.0, Top fans (XP, the list, rank
pills) in 0.82.0 and chat replays in 0.87.0 (all work in the headset). Other viewers' floating
reactions are deferred (`features/video-page.md`, "Deferred"). Left: the unverified items in `known-issues.md`, which the
README's "How you can help" asks users to check (`features/video-page.md`).
- [ ] **End screen**, **Info cards** (`features/video-page.md`).
- [ ] **Back button**: set aside (0.73.0); comes back on demand.
## Known quirks (`known-issues.md`)
+116
View File
@@ -37,6 +37,9 @@ backlogs in [features/](features/)):
and no bell), subscribe / unsubscribe / `setNotifications` (they report ok only when
YouTube's answer shows the new state). Filter chips: none on desktop for the tested
account (the documented ones seem app-only).
- `youtube-chat.js` (`ns.youtubeChat`): a live stream's chat ("Live chat" below): `info()`
(its views), `open(token)` (a view's chat page), `poll(next)`, and the chat items as plain
messages and changes (add, remove, banner).
The provider (`provider/`) is the only code that calls these modules on the shell's behalf.
@@ -153,6 +156,119 @@ Checked on a Lofi Girl stream:
as live (the player says "not at head" 15 s behind right after joining).
- No storyboard previews on live streams.
## Live chat
Probed signed in on a members' live stream and a busy public one (2026-10-16, console
snippets); read by `youtube-chat.js` since 0.79.0:
- **The watch page** has a `liveChatRenderer` (in `conversationBar`) naming the views ("Top
chat", "Live chat" in `sortFilterSubMenuRenderer.subMenuItems`), but **its tokens don't
work**: `/live_chat?continuation=<one of them>` answers 200 with an empty page (~79 KB,
`window["ytInitialData"] = <!-- …`, no chat), and `live_chat/get_live_chat` answers
**HTTP 400** (probe 6, 2026-10-16). Use the **chat frame's** tokens (its own view selector:
different tokens, each loading a full chat page, ~455 KB) or, with no frame on the page, the
**pop-out page**'s (`/live_chat?is_popout=1&v=<video id>`: ~452 KB of data, but no
`liveChatContinuation`; its view selector is read wherever it is: to confirm on a real page).
- **The chat page / chat frame** (`#chatframe`, same origin): its `ytInitialData` is shaped like
a chat update, `continuationContents.liveChatContinuation`, with `continuations[0]`
(`invalidationContinuationData`: the token to poll with), `header` (the view selector
again, with its own reload tokens), `ticker`, `actionPanel` (the message box, the emoji picker,
the reactions, the send endpoint `sendLiveChatMessageEndpoint`), `emojis` (the channel's
custom emojis), `participantsList`, `itemList`. Its `actions` are gone once the frame has
shown them (`undefined` when read later). The chat page's HTML writes it as
`window["ytInitialData"] = {…};`: fetching `/live_chat?continuation=<a view's reload token>`
and reading that (brace matching, then `JSON.parse`) works without any frame, and comes
with the recent messages in `actions` (77 on a busy stream). That's how to open the
**Live chat** view (every message) whatever view the page's frame shows (Top chat by default).
- **Polling:** `live_chat/get_live_chat` `{ continuation }` with the invalidation token
answers in ~100-200 ms with `actions` and the next `invalidationContinuationData`
(`timeoutMs: 10000`). YouTube's own frame doesn't wait that long: it is told about new
messages by a push service (`/punctual/v1/chooseServer`, `/punctual/multi-watch/channel`).
**Polling every 2 s works** (20 polls in a row on a busy stream: no errors, new messages
in each, no duplicates; each poll's token is the previous answer's).
- **A message:** `addChatItemAction.item.liveChatTextMessageRenderer`: `message.runs`
(text and emojis), `authorName.simpleText`, `authorPhoto.thumbnails` (32 and 64 px),
`authorExternalChannelId`, `id`, `timestampUsec`, `contextMenuEndpoint` ("Chat actions").
The action also has a `clientId`.
- **Other items seen** (in `addChatItemAction.item`): `liveChatPlaceholderItemRenderer`
(only `id` and `timestampUsec`: a message YouTube holds back; none was filled in within
40 s, so skip them), `liveChatViewerEngagementMessageRenderer` (YouTube's "Welcome to live
chat! Remember to guard your privacy…" notice: `icon`, `message.runs`, a "Learn more"
`actionButton` to support.google.com).
- **Banners:** `addBannerToLiveChatCommand.bannerRenderer.liveChatBannerRenderer` (`contents`,
`actionId`, `bannerType`, `bannerProperties` with `isEphemeral` / `autoCollapseDelay`).
Seen: `LIVE_CHAT_BANNER_TYPE_CHAT_SUMMARY`, an AI "Chat summary" ("Auto-generated
experiment • Quality may vary") with thumbs up / down, gone after 12-30 s. Pinned messages
use the same command (not seen yet).
- Not seen yet: Super Chats / Stickers, memberships, ticker items, deletions, replacements.
- **Custom emojis** (`liveChatContinuation.emojis`, 132 on that channel): `emojiId`
(`<channel id>/<id>`), `shortcuts` (`:_C80:`, `:C80:`), `searchTerms`, `image.thumbnails`
(24 and 48 px on yt3.ggpht.com), `isCustomEmoji`.
- **Reactions** (`reactionControlPanelViewModel.expandedButtons`): ❤ 😄 🎉, each a
`reactionControlPanelButtonViewModel` with `emojiId`, `buttonIcon` (Noto emoji PNG on
fonts.gstatic.com), `a11yLabel` ("Send ❤") and `onTap.innertubeCommand`: a
`feedbackEndpoint` (`feedbackToken`) to `/youtubei/v1/feedback`. They're in the chat page's
`actionPanel` (absent when the stream has no reactions, or signed out). Sent by
`youtubeChat.react()` as YouTube's web client sends a feedback token: `{ feedbackTokens:
[token], isFeedbackTokenUnencrypted: false, shouldMerge: false }`, the answer's
`feedbackResponses[].isProcessed` saying whether it was taken (0.80.0; checked from the
console on a live stream, 2026-10-16: taken). The set varies by stream (that one offered five,
not ❤ 😄 🎉). `emojiId` is written without the
emoji selector (❤ = U+2764 alone), which fonts draw as a plain glyph: add U+FE0F.
- **The message box** (probe 7, 2026-10-16; the chat page's `actionPanel`):
`liveChatMessageInputRenderer` with `inputField.liveChatTextInputFieldRenderer`
(`placeholder` "Chat...", `maxCharacterLimit`, `emojiCharacterCount`: 10, what an emoji
counts for), `sendButton…sendLiveChatMessageEndpoint` (`params`, `clientIdPrefix`, apiUrl
`/youtubei/v1/live_chat/send_message`), `pickers` (`emojiPickerRenderer`,
`liveChatProductPickerPanelViewModel`), `pickerButtons` (an icon toggle, the reactions),
`jewelsGiftsButton`. Where you can't chat, the action panel holds something else with a
`message` (not seen yet).
- **Sending**: `live_chat/send_message` `{ params, clientMessageId, richMessage: { textSegments:
[{ text } | { emojiId }] } }` posted a message (checked from the console: the answer's
`actions` echo it as an `addChatItemAction`, plus a `runAttestationCommand`). A channel
emoji as `{ emojiId }` is how YouTube's own client sends one (not checked yet).
- **Support** ("$", the product picker): its items are `liveChatProductPickerPanelItemViewModel`s
("Gifts", "Super Chat", "Membership gifting", "Free Member Milestone Chat" on a members'
stream). The chat frame's document had no button to click for it (only icon definitions), so
StreamXR doesn't open it: its Support button says to use YouTube's own window.
- **Chat replays** (a finished stream's chat; probe 10, 2026-10-16): the watch page's
`liveChatRenderer.isReplay` is true and its views are "Top chat replay" / "Live chat replay".
The chat frame is `/live_chat_replay`; its data (shown actions already gone, as for live chat)
has `continuations` [`liveChatReplayContinuationData` (`continuation`,
`timeUntilLastMessageMsec`), `playerSeekContinuationData` (`continuation`)] and the same view
selector (reload tokens: `/live_chat_replay?continuation=<token>` reads, ~295 KB, with the
chat). `live_chat/get_live_chat_replay` `{ continuation, currentPlayerState: { playerOffsetMs } }`
is a **cursor**: each answer is the next chunk (~45 messages, minutes of chat) as
`replayChatItemAction` (`videoOffsetTimeMsec`, the message's time in the video, and its
`actions`), whatever position is sent; the **first** token with another position starts
again from there (a seek: asked at 10:35, the chunk started at 10:36). So a reader fetches
ahead and shows each message when the video reaches its time (`youtube-chat.js`
`pollReplay`, `provider/live-chat.js`, 0.87.0). The frame itself made no chat requests while
probed (`/youtubei/v1/att/get` only); `playerSeekContinuationData` isn't used yet.
With YouTube's chat **hidden** on the page (probe 11), the watch page keeps the replay's
`liveChatRenderer` (its `initialDisplayState` still says EXPANDED) and the chat frame element
stays, so the chat is still found. There's **no pop-out replay page**:
`/live_chat_replay?is_popout=1&v=<id>` answers HTTP 400 (the fallback only works for live chat).
- **Top fans** (probes 8 and 9, 2026-10-16):
- The chat header's `viewerLeaderboardEntryPoint.liveViewerLeaderboardChatEntryPointViewModel`:
a `defaultButton` and a `pointsButton` (crown icons, "Top fans"), each tapping a
`showEngagementPanelEndpoint` (tag `PAlive_viewer_leaderboard`, `globalConfiguration.params`),
plus `pointsEntityKey` / `entryPointStateEntityKey`.
- **Your XP** is an entity: `frameworkUpdates.entityBatchUpdate.mutations[]` with that
`entityKey` and `payload.liveViewerLeaderboardPointsEntity` (`pointsCompactText` "0 XP",
`pointsValue`); it comes in the chat page and, when it changes, in the polls' answers.
- **The list**: `get_panel` `{ panelId: 'PAlive_viewer_leaderboard', params }` (with the params
alone, `browse` or other guesses: HTTP 400) answers
`content.engagementPanelSectionListRenderer` with `content.sectionListRenderer.contents[]`
(50 on a busy stream) of `itemSectionRenderer` > `liveLeaderboardItemViewModel`: `rank.content`
("1"), `points.content` ("30 XP"), `avatar.avatarViewModel.image.sources[0].url`,
`content.liveLeaderboardItemChannelContentViewModel` (`displayName.content`, `badge`: the
rank's button). Its `footer` is **your row** (`rank`, `points`, `avatar`, and
`liveLeaderboardItemActionContentViewModel.actionDescription` with a gift button).
- **Rank marks on messages**: `liveChatTextMessageRenderer.beforeContentButtons[]` >
`buttonViewModel` (`iconName` CROWN_FILLED, `title` "#1", `customBackgroundColor` /
`customFontColor`, ARGB; tapping opens the same panel): on the top three's messages.
## Playlists
- The player (getPlaylist / getPlaylistIndex) and the page's sidebar hold only a WINDOW of
+4 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "StreamXR",
"version": "0.78.9",
"version": "0.88.1",
"description": "Watch streaming video in a SteamVR headset: 360° and VR180 videos around you, regular videos on a cinema screen. Supports YouTube.",
"action": {
"default_title": "StreamXR: open YouTube and click the VR button (the headset)",
@@ -61,6 +61,7 @@
"src/core/page/vr-panel/styles/kit.js",
"src/core/page/vr-panel/styles/grid.js",
"src/core/page/vr-panel/styles/thread.js",
"src/core/page/vr-panel/styles/chat.js",
"src/core/page/vr-panel/styles/queue.js",
"src/core/page/vr-panel/styles/catalog.js",
"src/core/page/vr-panel/styles/profile.js",
@@ -94,6 +95,7 @@
"src/sites/youtube/page/youtube-channel.js",
"src/sites/youtube/page/youtube-home.js",
"src/sites/youtube/page/youtube-subscriptions.js",
"src/sites/youtube/page/youtube-chat.js",
"src/sites/youtube/page/provider/strings-en.js",
"src/sites/youtube/page/provider/strings-es.js",
"src/sites/youtube/page/provider/badges.js",
@@ -106,6 +108,7 @@
"src/sites/youtube/page/provider/description.js",
"src/sites/youtube/page/provider/comments-panel.js",
"src/sites/youtube/page/provider/settings.js",
"src/sites/youtube/page/provider/live-chat.js",
"src/sites/youtube/page/provider/panels.js",
"src/sites/youtube/page/provider/extras.js",
"src/sites/youtube/page/provider/provider.js",
+3 -1
View File
@@ -15,8 +15,10 @@
const ICONS = {
playpause: ['play_pause'],
recenter: ['recenter'],
grab: ['back_hand'], // the grip while it grabs (a move bar or handle, a resize corner) instead of recentering
pointer: ['ads_click'], // the trigger: point and click with the laser
panel: ['call_to_action'], // show / hide the panels (a panel with its bottom bar)
panel: ['bottom_panel_close'], // hide the panels (they're up)
panelopen: ['bottom_panel_open'], // show the panels again (they're hidden)
seek: ['replay_10', 'forward_10'], // the stick's left / right: back / forward 10 s
scroll: ['swap_vert'], // the stick scrolls the text you point at
keyboard: ['keyboard'],
+14 -1
View File
@@ -45,6 +45,19 @@
const aroundPivot = (pivot, rot) =>
multiply(translation(pivot[0], pivot[1], pivot[2]), multiply(rot, translation(-pivot[0], -pivot[1], -pivot[2])));
// The inverse of a pose made only of a rotation and a translation (no scaling): the rotation
// turned back (transposed), and the translation undone in that turned-back frame.
function invertRigid(m) {
const o = identity(), tx = m[12], ty = m[13], tz = m[14];
o[0] = m[0]; o[1] = m[4]; o[2] = m[8];
o[4] = m[1]; o[5] = m[5]; o[6] = m[9];
o[8] = m[2]; o[9] = m[6]; o[10] = m[10];
o[12] = -(m[0] * tx + m[1] * ty + m[2] * tz);
o[13] = -(m[4] * tx + m[5] * ty + m[6] * tz);
o[14] = -(m[8] * tx + m[9] * ty + m[10] * tz);
return o;
}
// Position p as seen after matrix m (rotation + translation).
const transformPoint = (m, p) => [
m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
@@ -76,7 +89,7 @@
}
ns.math3d = {
DEG, identity, multiply, translation, axisAngle, rotationY, aroundPivot,
DEG, identity, multiply, translation, axisAngle, rotationY, aroundPivot, invertRigid,
transformPoint, transformDir, cross, dot, length, normalize, yawOf,
};
})(globalThis.__streamxr = globalThis.__streamxr || {});
+15 -6
View File
@@ -1,6 +1,7 @@
// The "Auto-hide" settings tab, which belongs to the shell: how long each part of the
// interface waits, unused, before it hides or fades. One slider each (from 1 s to 30 s, or
// Never), with a Default button; remembered.
// Never), with a Default button; and a switch, "Auto-hide while paused": whether those waits also
// run while the video is paused (off by default: paused, everything stays up). Remembered.
// panels: the controls panel and the open panels (and the lasers with them), while
// the video plays (vr-panel.js)
// bars: the side toolbars and their open panels, while the video plays (vr-panel.js)
@@ -10,10 +11,11 @@
// A site's Options panel includes it as one of its tabs.
//
// ns.vrFadeOptions.seconds(name) -> the wait in seconds (Infinity: never)
// ns.vrFadeOptions.hideWhilePaused() -> bool (the switch)
// ns.vrFadeOptions.content() -> the Options style's 'rows' content
// ns.vrFadeOptions.event(ev) -> true if it was one of this tab's controls
// ('slider:fade-<name>', 'default:fade-<name>')
// ns.vrFadeOptions.reset() -> back to the defaults (the global reset)
// ('slider:fade-<name>', 'default:fade-<name>', 'switch:fade-paused')
// ns.vrFadeOptions.reset() -> back to the defaults, the switch off (the global reset)
(function (ns) {
'use strict';
@@ -30,6 +32,8 @@
for (const n of NAMES) if (n in saved && (saved[n] === null || STOPS.includes(saved[n]))) times[n] = saved[n];
}
const remember = () => ns.storage.setJSON(KEY, times);
const PAUSED_KEY = 'hideWhilePaused'; // storage.js name: 'on' while the switch is on
const hideWhilePaused = () => ns.storage.get(PAUSED_KEY) === 'on';
const seconds = name => (times[name] === null ? Infinity : times[name] != null ? times[name] : DEFAULTS[name]);
@@ -42,11 +46,16 @@
return {
kind: 'rows', note: tr('fade.note'),
rows: NAMES.map(n => ({ label: tr(`fade.${n}`), hint: tr(`fade.${n}Hint`),
control: { type: 'slider', id: `fade-${n}`, fraction: fractionOf(times[n]), text: text(times[n]), enabled: true, defaultButton: true } })),
control: { type: 'slider', id: `fade-${n}`, fraction: fractionOf(times[n]), text: text(times[n]), enabled: true, defaultButton: true } }))
.concat({ label: tr('fade.paused'), hint: tr('fade.pausedHint'), control: { type: 'switch', id: 'fade-paused', on: hideWhilePaused() } }),
};
}
function event(ev) {
if (ev.type === 'switch' && ev.value === 'fade-paused') {
if (hideWhilePaused()) ns.storage.remove(PAUSED_KEY); else ns.storage.set(PAUSED_KEY, 'on');
return true;
}
const n = typeof ev.value === 'string' && ev.value.startsWith('fade-') ? ev.value.slice(5) : null;
if (!NAMES.includes(n)) return false;
if (ev.type === 'slider') times[n] = stopAt(ev.fraction);
@@ -57,7 +66,7 @@
}
// Back to the defaults (the global reset); forget the choices.
function reset() { Object.assign(times, DEFAULTS); ns.storage.remove(KEY); }
function reset() { Object.assign(times, DEFAULTS); ns.storage.remove(KEY); ns.storage.remove(PAUSED_KEY); }
ns.vrFadeOptions = { seconds, content, event, reset, DEFAULTS };
ns.vrFadeOptions = { seconds, hideWhilePaused, content, event, reset, DEFAULTS };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+62 -11
View File
@@ -6,10 +6,13 @@
// The layout comes from keyboard-options.js (the Keyboard settings tab): a chosen style and
// language, or by default the computer's keyboard layout and the browser's language. The
// space bar names the language, and the symbols page has a row of its accented letters.
// Shift (tap: the next letter; tap twice: caps lock), "?123" for a page of symbols, and a
// key that hides the keyboard.
// Shift (tap: the next letter; tap twice: caps lock), "?123" for a page of symbols, 😀 for
// pages of emojis (the ones the panel being typed into hands over first, e.g. a channel's
// own, drawn as pictures; then a standard set), and a key that hides the keyboard.
//
// const kb = ns.vrKeyboard.create();
// kb.setEmojis([{ text, image, label }]) the panel's own emojis (text: what the key types,
// e.g. ':_wave:'; image: its picture's URL); [] for none
// kb.canvas, kb.aspect (height / width), kb.version() (changes when its look changes),
// kb.animating() (a key's hover / press is still easing: redraw)
// kb.draw({ hover, pressed, target }) hover / pressed: its own control ids; target: what
@@ -34,14 +37,31 @@
const BAR = { w: 240, h: 30, zone: 500 }, CORNER = { r: 34, hit: 52 }; // the grab bar's pill (zone: its clickable width)
// Physical keys by row (KeyboardEvent.code), with their widths in key units; null = a gap.
// Specials: 'Backspace', 'Enter', 'Shift', 'Space', 'Symbols' (?123 / ABC), 'Hide'.
// Specials: 'Backspace', 'Enter', 'Shift', 'Space', 'Symbols' (?123 / ABC), 'Emoji' (the
// emoji pages), 'Hide'.
const BOTTOM = [['Symbols', 2], ['Emoji', 1.5], ['Space', 9], ['Hide', 2.5]];
const LETTERS = [
[['Backquote', 1], ...'1234567890'.split('').map(d => [`Digit${d}`, 1]), ['Minus', 1], ['Equal', 1], ['Backspace', 2]],
[[null, 0.75], ...'QWERTYUIOP'.split('').map(c => [`Key${c}`, 1]), ['BracketLeft', 1], ['BracketRight', 1], ['Backslash', 1], [null, 1.25]],
[[null, 1], ...'ASDFGHJKL'.split('').map(c => [`Key${c}`, 1]), ['Semicolon', 1], ['Quote', 1], ['Enter', 3]],
[['Shift', 2.25], ['IntlBackslash', 1], ...'ZXCVBNM'.split('').map(c => [`Key${c}`, 1]), ['Comma', 1], ['Period', 1], ['Slash', 1], ['Shift', 1.75]],
BOTTOM,
];
// The emoji pages ('emo:<n>' keys, n counting from the page's first): 13 across and 4 down,
// Backspace, ‹ (the page before), Enter and › (the page after) at the rows' ends.
const EMOJI_COLS = 13, EMOJI_ROWS = 4, PER_EMOJI_PAGE = EMOJI_COLS * EMOJI_ROWS;
const EMOJI_ENDS = ['Backspace', 'EmojiPrev', 'Enter', 'EmojiNext'];
const emojiRows = count => [
...EMOJI_ENDS.map((end, r) => [...Array.from({ length: EMOJI_COLS }, (_, c) => (r * EMOJI_COLS + c < count ? [`emo:${r * EMOJI_COLS + c}`, 1] : [null, 1])), [end, 2]]),
[['Symbols', 2.5], ['Space', 10], ['Hide', 2.5]],
];
// The standard emojis offered after the panel's own (the faces, hands and symbols people use
// most in chats).
const STANDARD_EMOJIS = ('😀 😃 😄 😁 😆 😅 🤣 😂 🙂 😉 😊 😇 🥰 😍 🤩 😘 😋 😛 😜 🤪 😝 🤗 🤭 🤔 🤐 😐 😑 😶 😏 😒 '
+ '🙄 😬 😌 😔 😪 😴 😷 🤒 🥵 🥶 😵 🤯 🤠 🥳 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 '
+ '😓 😩 😫 🥱 😤 😡 😠 🤬 😈 💀 💩 🤡 👻 👽 🤖 😺 😹 😻 🙈 🙉 🙊 💯 💢 💥 💫 💦 💨 💬 💤 👋 🤚 ✋ 👌 🤌 ✌️ 🤞 '
+ '🤟 🤘 🤙 👈 👉 👆 👇 👍 👎 ✊ 👊 👏 🙌 👐 🤲 🙏 💪 🧠 👀 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ❣️ 💕 💖 💗 💘 💝 🔥 ✨ ⭐ '
+ '🌟 🎉 🎊 🎈 🎁 🏆 🥇 🎮 🎧 🎵 🎶 📣 📢 ✅ ❌ ❓ ❗ ⚡ 🌈 ☀️ 🌙 🍕 🍔 🍿 ☕ 🍺 🚀 👑 💎 💰 🕶️ 🫡 🫶').split(' ');
// The symbols page ('sym:<char>' keys): the digits row stays; then symbols, with the
// language's currency in the currency spot; and a last row of up to 13: the language's
// accented letters and the other currencies (keyboard-options.js).
@@ -51,7 +71,7 @@
[[null, 0.75], ...sym(['!', '@', '#', currency, '%', '^', '&', '*', '(', ')', '_', '+', '|']), [null, 1.25]],
[[null, 1], ...sym('~ { } < > : ; " \' ? /'.split(' ')), ['Enter', 3]],
[[null, (UNITS - Math.min(13, last.length)) / 2], ...sym(last.slice(0, 13)), [null, (UNITS - Math.min(13, last.length)) / 2]],
[['Symbols', 2.5], ['Space', 10], ['Hide', 2.5]],
BOTTOM,
];
function create() {
@@ -59,12 +79,24 @@
canvas.width = W; canvas.height = H;
const g = canvas.getContext('2d');
let layout = ns.vrKeyboardOptions.layout(); // { chars, shifted, currency, accents, locale, styleName, languageName, version }
let page = 'letters', shift = 'off', lastShiftAt = 0; // shift: 'off' | 'once' | 'caps'
let page = 'letters', shift = 'off', lastShiftAt = 0; // page: 'letters' | 'symbols' | 'emoji'; shift: 'off' | 'once' | 'caps'
let ver = 0, regions = [], keys = []; // keys: this page's [{ n, code, x, y, w, h }]
const bump = () => { ver++; };
// The emojis: the panel's own ({ text, image, label }), then the standard ones; which page.
let ownEmojis = [], ownKey = '', emojiPage = 0;
const kit = ns.vrKit.createCardKit(g, bump, 240); // their pictures (a redraw when one arrives)
const emojis = () => [...ownEmojis, ...STANDARD_EMOJIS.map(e => ({ text: e, label: e }))];
const emojiPages = () => Math.max(1, Math.ceil(emojis().length / PER_EMOJI_PAGE));
const emojiAt = n => emojis()[emojiPage * PER_EMOJI_PAGE + n] || null;
function setEmojis(list) {
const key = (list || []).map(e => e.text).join('\u0001');
if (key === ownKey) return;
ownKey = key; ownEmojis = (list || []).filter(e => e && e.text); emojiPage = 0; bump();
}
// What a key types now (Shift applied), or '' for a key this layout doesn't have.
function charOf(code) {
if (code.startsWith('emo:')) { const e = emojiAt(+code.slice(4)); return e ? e.text : ''; }
if (code.startsWith('sym:')) return code.slice(4);
const c = layout.chars[code];
if (!c) return '';
@@ -80,6 +112,9 @@
if (code === 'Shift') return '⇧';
if (code === 'Space') return layout.languageName;
if (code === 'Symbols') return page === 'letters' ? '?123' : 'ABC';
if (code === 'Emoji') return '😀';
if (code === 'EmojiPrev' || code === 'EmojiNext') return code;
if (code.startsWith('emo:')) { const e = emojiAt(+code.slice(4)); return e ? e.label || e.text : ''; }
if (code === 'Hide') return ns.i18n.t('kb.hide');
return charOf(code);
}
@@ -113,7 +148,10 @@
g.fillText(`${layout.styleName} · ${layout.languageName}`, SIDE + BODY_W - 28, HEADER_H / 2 + 4);
// Keys
const unit = (BODY_W - PAD * 2) / UNITS;
(page === 'letters' ? LETTERS : symbols(layout.currency, layout.accents)).forEach((row, r) => {
if (emojiPage >= emojiPages()) emojiPage = 0;
const rows = page === 'letters' ? LETTERS : page === 'emoji'
? emojiRows(Math.min(PER_EMOJI_PAGE, emojis().length - emojiPage * PER_EMOJI_PAGE)) : symbols(layout.currency, layout.accents);
rows.forEach((row, r) => {
let x = SIDE + PAD;
const y = HEADER_H + r * ROW_H;
for (const [code, w] of fitRow(row)) {
@@ -151,10 +189,13 @@
// Backspace, Shift and Hide are Material Symbols (Shift filled while on, with a bar under
// it for caps lock); Enter is ↵ and its name. Pointed at: the white ring; pressed:
// squarer corners and a 0.12 state layer.
const ICONS = { Backspace: 'backspace', Shift: 'shift', Hide: 'keyboard_hide' };
const ICONS = { Backspace: 'backspace', Shift: 'shift', Hide: 'keyboard_hide', EmojiPrev: 'chevron_left', EmojiNext: 'chevron_right' };
function drawKey(k, label, state, id) {
const { h: hv, p: pv } = ns.vrMotion.of(state, id); // pointed at / pressed, eased (motion.js)
const special = !/^(Key|Digit|sym:|Backquote|Minus|Equal|Bracket|Backslash|Semicolon|Quote|Comma|Period|Slash|IntlBackslash|Space)/.test(k.code);
const special = !/^(Key|Digit|sym:|emo:|Backquote|Minus|Equal|Bracket|Backslash|Semicolon|Quote|Comma|Period|Slash|IntlBackslash|Space)/.test(k.code);
// (an emoji page's ‹ / › with no page that way: faded)
const dim = (k.code === 'EmojiPrev' && emojiPage === 0) || (k.code === 'EmojiNext' && emojiPage >= emojiPages() - 1);
if (dim) g.globalAlpha = 0.38;
const lit = k.code === 'Enter' || (k.code === 'Shift' && shift !== 'off');
const [bg, fg] = lit ? [P.brand, P.onBrand] : special ? [P.secondaryContainer, P.onSecondaryContainer] : [P.surfaceContainerHighest, P.onSurface];
const rad = Math.max(3, ns.vrMotion.lerp(T.shape.large, T.shape.small, pv)); // squares up as it's pressed, springs back
@@ -170,11 +211,17 @@
const tw = g.measureText(label).width, x0 = cx - (34 + 10 + tw) / 2;
ns.vrSymbols.draw(g, 'keyboard_return', x0 + 17, cy, 34, false);
g.textAlign = 'left'; g.fillText(label, x0 + 44, cy + 2);
} else if (k.code.startsWith('emo:') && (emojiAt(+k.code.slice(4)) || {}).image) {
const img = kit.image(emojiAt(+k.code.slice(4)).image), s = 48; // a panel's own emoji: its picture
if (img) kit.drawImageContain(img, cx - s / 2, cy - s / 2, s, s);
else { g.fillStyle = PA('onSurface', 0.12); roundRect(g, cx - s / 2, cy - s / 2, s, s, 10); g.fill(); }
} else {
g.font = k.code === 'Space' ? T.font('labelLarge', 22) : special ? T.font('labelLarge', 26) : `36px ${FONT}`;
g.font = k.code === 'Space' ? T.font('labelLarge', 22) : k.code === 'Emoji' || k.code.startsWith('emo:') ? `40px ${FONT}`
: special ? T.font('labelLarge', 26) : `36px ${FONT}`;
g.fillText(label, cx, cy + 2);
}
ns.vrKit.hoverRing(g, k.x, k.y, k.w, k.h, rad, hv);
g.globalAlpha = 1;
}
const keyOf = id => (id && id.startsWith('key:') ? keys[+id.slice(4)] : null);
@@ -185,6 +232,10 @@
if (!k) return null;
if (k.code === 'Hide') return { hide: true };
if (k.code === 'Symbols') { page = page === 'letters' ? 'symbols' : 'letters'; shift = 'off'; bump(); return null; }
if (k.code === 'Emoji') { page = 'emoji'; shift = 'off'; bump(); return null; }
if (k.code === 'EmojiPrev' || k.code === 'EmojiNext') {
emojiPage = Math.max(0, Math.min(emojiPages() - 1, emojiPage + (k.code === 'EmojiNext' ? 1 : -1))); bump(); return null;
}
if (k.code === 'Shift') {
const now = performance.now();
shift = shift === 'off' ? (now - lastShiftAt < 400 ? 'caps' : 'once') : shift === 'once' && now - lastShiftAt < 400 ? 'caps' : 'off';
@@ -197,7 +248,7 @@
return c ? { key: c } : null;
}
const repeats = id => { const k = keyOf(id); return !!k && !/^(Shift|Symbols|Hide|Enter)$/.test(k.code); };
const repeats = id => { const k = keyOf(id); return !!k && !/^(Shift|Symbols|Emoji|EmojiPrev|EmojiNext|Hide|Enter|emo:)/.test(k.code); };
const hitTest = (u, v) => {
const x = u * W, y = v * H;
for (const r of regions) if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) return { id: r.id };
@@ -208,7 +259,7 @@
return (x >= SIDE && x <= SIDE + BODY_W && y <= BODY_H) || !!hitTest(u, v);
};
return { canvas, aspect: H / W, width: W, height: H, draw, hitTest, solidAt, press, repeats, animating: motion.moving, version: () => `${ver}.${ns.vrKeyboardOptions.layout().version}` }; // its own changes, and the layout's
return { canvas, aspect: H / W, width: W, height: H, draw, hitTest, solidAt, press, repeats, setEmojis, animating: motion.moving, version: () => `${ver}.${ns.vrKeyboardOptions.layout().version}` }; // its own changes, and the layout's
}
ns.vrKeyboard = { create };
+57 -14
View File
@@ -8,14 +8,20 @@
// back when the laser leaves it. Everything outside the bar and the grown pills is see-through
// (solidAt), so the laser reaches the panel behind.
//
// const tb = ns.vrOrbitersUI.createToolbar(items, grows); items: [{ id, label, icon }]
// const tb = ns.vrOrbitersUI.createToolbar(items, grows, opts); items: [{ id, label, icon }]
// (the id is also the control id; label: text or a function giving it; icon: a
// Material Symbols name, symbols.js); grows: 'left' or 'right', the side the names
// grow towards (away from the video)
// tb.draw({ open: { <item id>: bool }, hover, pressed, now (ms) })
// grow towards (away from the video); opts.handle: { id }, a grab bar beside the bar on the
// video side (an upright pill with a drag handle, as the keyboard's bar, lying down there;
// the handle in the brand colour while `held`, the white ring while pointed at; its
// clickable zone wider than the pill)
// tb.draw({ open: { <item id>: bool }, hover, pressed, held (the handle's id while it's held), now (ms) })
// tb.setHidden({ <item id>: true }) items left out (the bar shrinks to the rest, from its
// top; the canvas keeps room for them all); returns whether that changed anything
// tb.hitTest(u, v) -> { id } | null; tb.solidAt(u, v); tb.moving() (keep redrawing)
// tb.barCentre: the bar's centre in canvas pixels { x, y }, tb.barWidth (px) (the bar isn't
// centred in the canvas: the room for the names is on one side)
// tb.barCentre: the bar's centre in canvas pixels { x, y } (kept up to date by setHidden),
// tb.barWidth (px) (the bar isn't centred in the canvas: the room for the names is on one
// side), tb.handleRoom (px: the room the grab bar takes beside the bar, on the video side)
//
// The pill: one M3 tonal button (icon + name), filled while its panel is open; e.g. Settings
// on the controls panel's top edge.
@@ -48,14 +54,39 @@
return `rgb(${A.map((v, i) => Math.round(v + (B[i] - v) * t)).join(',')})`;
}
function createToolbar(items, grows) {
function createToolbar(panelItems, grows, opts = {}) {
const BAR_W = 104, PAD = 8, GAP = 6, ITEM = BAR_W - PAD * 2; // the bar's width; an item is ITEM px square
const ICON = 40, LABEL_GAP = 14, END = 30; // a grown pill: icon, gap, room after the name
const ROOM = 300, MARGIN = 10; // the most a name can grow; room for the ring
const BAR_H = PAD * 2 + items.length * ITEM + (items.length - 1) * GAP;
const TW = MARGIN * 2 + BAR_W + ROOM, TH = MARGIN * 2 + BAR_H;
// The grab bar: an upright pill GRAB.w wide (at most GRAB.maxH tall, GRAB.inset shorter than
// the bar), GRAB.gap from the bar on the video side; its clickable zone GRAB.reach wider on
// each side and above / below.
const GRAB = { w: 30, gap: 10, maxH: 240, inset: 48, reach: 14 };
const handle = opts.handle || null;
const HANDLE_ROOM = handle ? GRAB.gap + GRAB.w + GRAB.reach : 0;
const items = panelItems;
const heightFor = n => PAD * 2 + n * ITEM + Math.max(0, n - 1) * GAP; // the bar with n items
const TW = MARGIN * 2 + BAR_W + ROOM + HANDLE_ROOM, TH = MARGIN * 2 + heightFor(items.length);
const left = grows === 'left';
const barX = left ? TW - MARGIN - BAR_W : MARGIN, barY = MARGIN;
// (the names grow away from the video; the grab bar is on the video side)
const barX = left ? TW - MARGIN - HANDLE_ROOM - BAR_W : MARGIN + HANDLE_ROOM, barY = MARGIN;
let shownItems = items, BAR_H = heightFor(items.length), hiddenKey = '';
const barCentre = { x: barX + BAR_W / 2, y: barY + BAR_H / 2 };
function setHidden(hidden = {}) {
const key = items.filter(it => hidden[it.id]).map(it => it.id).join(',');
if (key === hiddenKey) return false;
hiddenKey = key;
shownItems = items.filter(it => !hidden[it.id]);
BAR_H = heightFor(shownItems.length);
barCentre.y = barY + BAR_H / 2;
return true;
}
// Where the grab bar is now (it follows the bar's height): { x, y, w, h }, or null.
function grabRect() {
if (!handle) return null;
const h = Math.max(60, Math.min(GRAB.maxH, BAR_H - GRAB.inset));
return { x: left ? barX + BAR_W + GRAB.gap : barX - GRAB.gap - GRAB.w, y: barY + (BAR_H - h) / 2, w: GRAB.w, h };
}
const canvas = document.createElement('canvas');
canvas.width = TW; canvas.height = TH;
const g = canvas.getContext('2d');
@@ -82,10 +113,21 @@
}
g.clearRect(0, 0, TW, TH);
bodies = [];
// The grab bar (as the keyboard's, upright): the panels' background, a drag handle (the
// brand colour while held), the white ring while pointed at or held.
const gr = grabRect();
if (gr) {
const hot = state.hover === handle.id, heldNow = state.held === handle.id;
g.fillStyle = T.panelFill(); roundRect(g, gr.x, gr.y, gr.w, gr.h, gr.w / 2); g.fill();
g.fillStyle = heldNow ? P.brand : P.onSurfaceVariant; roundRect(g, gr.x + gr.w / 2 - 2.5, gr.y + gr.h / 2 - 32, 5, 64, 2.5); g.fill();
if (hot || heldNow) hoverRing(g, gr.x, gr.y, gr.w, gr.h, gr.w / 2);
}
if (!shownItems.length) return;
// The bar: the panels' glass tint while its side has no panel open; solid while one is
// (it stands over that panel's edge, which mustn't show through), fading as it opens / closes.
// (the sum, so switching panels on that side, one fading out as the other fades in, stays solid)
const solid = Math.min(1, items.reduce((a, it) => a + sel.get(it.id), 0));
const solid = Math.min(1, shownItems.reduce((a, it) => a + sel.get(it.id), 0));
g.fillStyle = T.panelFill();
roundRect(g, barX, barY, BAR_W, BAR_H, BAR_W / 2); g.fill();
if (solid > 0.01) {
@@ -93,7 +135,7 @@
roundRect(g, barX, barY, BAR_W, BAR_H, BAR_W / 2); g.fill(); g.globalAlpha = 1;
}
g.font = T.font('labelLarge', 26);
bodies = items.map((it, i) => {
bodies = shownItems.map((it, i) => {
const label = labelText(it), e = 1 - Math.pow(1 - grow.get(it.id), 3), s = smooth(sel.get(it.id));
const room = Math.min(ROOM, LABEL_GAP + g.measureText(label).width + END) * e;
const y = barY + PAD + i * (ITEM + GAP), x = barX + PAD;
@@ -126,9 +168,10 @@
// Which item is at (u, v) (0..1 from the top-left), as last drawn? Anywhere on the bar
// counts (the nearest item, so the laser doesn't leave the toolbar between items), and
// so does a grown pill. -> { id } or null.
// so does a grown pill, and the grab bar (with room around it). -> { id } or null.
function hitTest(u, v) {
const px = u * TW, py = v * TH;
const px = u * TW, py = v * TH, gr = grabRect();
if (gr && px >= gr.x - GRAB.reach && px <= gr.x + gr.w + GRAB.reach && py >= gr.y - GRAB.reach && py <= gr.y + gr.h + GRAB.reach) return { id: handle.id };
for (const b of bodies) if (b.e > 0.3 && px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + ITEM) return { id: b.id };
if (!bodies.length || px < barX || px > barX + BAR_W || py < barY || py > barY + BAR_H) return null;
let best = null, bestD = Infinity;
@@ -139,7 +182,7 @@
const solidAt = (u, v) => !!hitTest(u, v);
// Still animating? (A name growing or shrinking, or a selection fading.)
const moving = () => items.some(it => grow.get(it.id) !== growGoal.get(it.id) || sel.get(it.id) !== selGoal.get(it.id));
return { canvas, aspect: TH / TW, draw, hitTest, solidAt, moving, barCentre: { x: barX + BAR_W / 2, y: barY + BAR_H / 2 }, barWidth: BAR_W };
return { canvas, aspect: TH / TW, draw, setHidden, hitTest, solidAt, moving, barCentre, barWidth: BAR_W, handleRoom: HANDLE_ROOM };
}
function createPill(item) {
+2 -1
View File
@@ -25,7 +25,8 @@
// queue: null | { hasPrev, hasNext } (in a list: Previous / Next at
// the seek bar's ends, ids pl:prev / pl:next) },
// quality: { current, currentLabel, levels, icons (optional: Material Symbols names
// shown on its button instead of icon + name: one, or two stacked) },
// shown on its button instead of icon + name: one, or two stacked; each
// level can have its own, shown in the Quality menu: popups-ui.js) },
// format: { id, label }, speed,
// volume: { level, muted }, chapters: [{ title, start }],
// heatmap: [{ start, duration, intensity (0..1) }] (how much each part is rewatched:
+33 -15
View File
@@ -10,7 +10,9 @@
// right above its button.
// const menu = ns.vrPopupsUI.createMenu();
// menu.draw(m, { hover, pressed }) m: { items: [{ id, text ("1080p" or "1080p\nPremium"),
// on (the current one: ticked), disabled }], note (a grey line above the items), scroll
// on (the current one: ticked), disabled, icons (optional: Material Symbols names drawn
// on the row's left, e.g. ['4k', '60fps']; the tick then moves to the right end) }],
// note (a grey line above the items), scroll
// (a long list scrolls: see drawList), key, width }
// or { volume: { level (0-100), muted } }; ids: the items', or 'vol:mute' / 'vol:slider'
// menu.hitTest(u, v) -> { id, fraction? } | null; menu.solidAt(u, v); menu.moving()
@@ -22,8 +24,9 @@
// The dialog: a title, an optional line of text and two buttons (cancel, confirm) in an M3
// dialog card; the scrim (the dim layer behind it) is drawn by vr-panel.js.
// const dlg = ns.vrPopupsUI.createDialog();
// dlg.draw(d, { hover, pressed }) d: { title, text, cancel, confirm, danger (the confirm
// button in the error colour) }; ids 'dialog:cancel', 'dialog:confirm'
// dlg.draw(d, { hover, pressed }) d: { title, text (up to four lines: the card grows to fit), cancel (null: no
// cancel button, for a dialog that only informs), confirm, danger (the confirm button in
// the error colour) }; ids 'dialog:cancel', 'dialog:confirm'
// dlg.hitTest(u, v) -> { id } | null; dlg.solidAt(u, v) (on the card); dlg.moving()
(function (ns) {
'use strict';
@@ -37,6 +40,7 @@
const W = 680, H = 760; // room for the widest (volume) and tallest (a long list)
const LIST_W = 360, ROW_H = 64, MIN_ROW_H = 40, PAD = 8, NOTE_H = 48; // rows shrink (to MIN_ROW_H) so a long list fits
const SCROLL_ROWS = 8; // a scrolling list shows this many rows
const ICON = 32, ICON_STEP = 36, ICON_LEFT = 20; // items' leading icons: size, spacing, left margin (px)
const VOL = { w: 640, h: 112 };
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
@@ -70,8 +74,13 @@
// (scrollBy, the laser's stick or a trigger drag: vr-panel.js), a thin bar on its right
// saying where; m.key: a new one starts it at the top. m.width: the card's width (px;
// default LIST_W).
// Items with `icons` get them on the left, in a column as wide as the most icons in any
// row (so the names line up); the card grows by that much, and the tick goes to the right.
function drawList(m, ui) {
const items = m.items || [], listW = Math.min(W, m.width || LIST_W);
const items = m.items || [];
const lead = Math.max(0, ...items.map(it => (it.icons || []).length));
const textX = lead ? ICON_LEFT + lead * ICON_STEP + 12 : 64; // where the names start in a row
const listW = Math.min(W, (m.width || LIST_W) + (lead ? textX - 64 + 40 : 0));
const noteH = m.note ? NOTE_H : 0;
const rowH = m.scroll ? ROW_H : Math.max(MIN_ROW_H, Math.min(ROW_H, Math.floor((H - PAD * 2 - noteH) / Math.max(1, items.length))));
const shownRows = m.scroll ? Math.min(items.length, SCROLL_ROWS) : items.length;
@@ -101,12 +110,13 @@
g.globalAlpha = it.disabled ? 0.38 : 1;
const fg = it.on ? P.onSecondaryContainer : P.onSurface;
g.fillStyle = fg;
if (it.on) S.draw(g, 'check', r.x + 32, r.y + r.h / 2, 28, false);
const lines = String(it.text).split('\n');
if (it.on) S.draw(g, 'check', lead ? r.x + r.w - 30 : r.x + 32, r.y + r.h / 2, 28, false);
(it.icons || []).forEach((name, k) => S.draw(g, name, r.x + ICON_LEFT + ICON_STEP * k + ICON_STEP / 2, r.y + r.h / 2, ICON, false));
const lines = String(it.text).split('\n'), textW = r.w - textX - (lead ? 52 : 12);
g.textAlign = 'left'; g.textBaseline = 'middle';
g.font = T.font('labelLarge', 24);
g.fillText(clip(lines[0], r.w - 76), r.x + 64, r.y + r.h / 2 + (lines.length > 1 ? -11 : 1));
if (lines.length > 1) { g.font = T.font('labelMedium', 17); g.fillStyle = it.on ? fg : P.onSurfaceVariant; g.fillText(clip(lines.slice(1).join(' '), r.w - 76), r.x + 64, r.y + r.h / 2 + 15); }
g.fillText(clip(lines[0], textW), r.x + textX, r.y + r.h / 2 + (lines.length > 1 ? -11 : 1));
if (lines.length > 1) { g.font = T.font('labelMedium', 17); g.fillStyle = it.on ? fg : P.onSurfaceVariant; g.fillText(clip(lines.slice(1).join(' '), textW), r.x + textX, r.y + r.h / 2 + 15); }
g.globalAlpha = 1;
hoverRing(g, r.x, r.y, r.w, r.h, rad, hv);
});
@@ -163,7 +173,9 @@
}
function createDialog() {
const W = 720, H = 220, PAD = 32, BTN_H = 56; // the title, a line of text, the buttons
const W = 720, PAD = 32, BTN_H = 56, LINE = 30, MAX_LINES = 4; // the title, up to four lines of text, the buttons
const TEXT_TOP = PAD + 50; // where the text starts in the card
const H = TEXT_TOP + MAX_LINES * LINE + 20 + BTN_H + PAD; // room for the tallest card
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const g = canvas.getContext('2d');
@@ -172,21 +184,27 @@
// An M3 dialog: the panels' glass background, extra-large corners; the title (headline), the
// text, then the buttons at the bottom right: cancel (tonal) and confirm (filled, or
// the error colour for something that can't be undone).
// the error colour for something that can't be undone). The card is as tall as its text
// needs, centred in the canvas (the rest see-through).
function draw(d, ui) {
ui = motion.step(ui);
regions = [];
g.clearRect(0, 0, W, H);
g.fillStyle = T.panelFill(); roundRect(g, 0, 0, W, H, T.shape.extraLarge); g.fill(); // the panels' glass (or solid)
g.font = T.font('bodyMedium', 22);
const lines = d.text ? ns.vrKit.wrap(g, d.text, W - PAD * 2, MAX_LINES) : [];
const cardH = (lines.length ? TEXT_TOP + lines.length * LINE : TEXT_TOP - 10) + 20 + BTN_H + PAD;
const y0 = Math.round((H - cardH) / 2);
g.fillStyle = T.panelFill(); roundRect(g, 0, y0, W, cardH, T.shape.extraLarge); g.fill(); // the panels' glass (or solid)
g.fillStyle = P.onSurface; g.textAlign = 'left'; g.textBaseline = 'top';
g.fillText(fitFont(g, d.title || '', W - PAD * 2, 32, '500 '), PAD, PAD);
if (d.text) { g.fillStyle = P.onSurfaceVariant; g.fillText(fitFont(g, d.text, W - PAD * 2, 22), PAD, PAD + 50); }
g.fillText(fitFont(g, d.title || '', W - PAD * 2, 32, '500 '), PAD, y0 + PAD);
g.fillStyle = P.onSurfaceVariant; g.font = T.font('bodyMedium', 22);
lines.forEach((line, i) => g.fillText(line, PAD, y0 + TEXT_TOP + i * LINE));
g.font = T.font('labelLarge', 22);
const bw = t => Math.max(140, g.measureText(t).width + 56);
const cw = bw(d.confirm || ''), kw = bw(d.cancel || '');
const by = H - PAD - BTN_H;
const by = y0 + cardH - PAD - BTN_H;
button(g, regions, 'dialog:confirm', W - PAD - cw, by, cw, BTN_H, { label: d.confirm, kind: d.danger ? 'danger' : 'filled' }, ui);
button(g, regions, 'dialog:cancel', W - PAD - cw - 16 - kw, by, kw, BTN_H, { label: d.cancel, kind: 'tonal' }, ui);
if (d.cancel != null) button(g, regions, 'dialog:cancel', W - PAD - cw - 16 - kw, by, kw, BTN_H, { label: d.cancel, kind: 'tonal' }, ui);
}
function hitTest(u, v) { const x = u * W, y = v * H; const r = regions.find(q => inside(q, x, y)); return r ? { id: r.id } : null; }
return { canvas, aspect: H / W, draw, hitTest, solidAt: () => true, moving: motion.moving };
+15 -5
View File
@@ -1,18 +1,23 @@
// The "Safe zone" settings tab, which belongs to the shell (the layout is the shell's,
// whatever the site): three sliders for the Safe Zone (safe-zone.js), each with a Default
// button: its width and height as shares of what the headset shows, and its centre above or
// below eye level. While the tab is open, an outline shows the zone around you. A site's
// Options panel includes it as one of its tabs.
// below eye level; and a switch, "Recenter resets panels": whether recentering also puts the
// panels you moved back in their places (off: they come along, where you put them; remembered).
// While the tab is open, an outline shows the zone around you. A site's Options panel includes
// it as one of its tabs.
//
// ns.vrSafeZoneOptions.content() -> the Options style's 'rows' content (and asks for the outline)
// ns.vrSafeZoneOptions.event(ev) -> true if it was one of this tab's controls
// ('slider:<width|height|shift>', 'default:<width|height|shift>')
// ('slider:<width|height|shift>', 'default:<width|height|shift>', 'switch:sz-recenter')
// ns.vrSafeZoneOptions.recenterResets() -> bool; ns.vrSafeZoneOptions.reset() (the switch off)
(function (ns) {
'use strict';
const SZ = ns.vrSafeZone;
const IDS = ['width', 'height', 'shift'];
const tr = (key, vars) => ns.i18n.t(key, vars); // words in the interface language (i18n.js)
const RECENTER_KEY = 'recenterResets'; // storage.js name: 'on' while the switch is on
const recenterResets = () => ns.storage.get(RECENTER_KEY) === 'on';
// A slider's position (0..1) for a value in its range, and back.
const fractionOf = (id, x) => { const [a, b] = SZ.RANGES[id]; return (x - a) / (b - a); };
@@ -35,12 +40,17 @@
});
return {
kind: 'rows', note: tr('safezone.note', { h: SZ.fov.h, v: SZ.fov.v }),
rows: [row('width', ['narrower', 'wider']), row('height', ['shorter', 'taller']), row('shift', ['lower', 'higher'])],
rows: [row('width', ['narrower', 'wider']), row('height', ['shorter', 'taller']), row('shift', ['lower', 'higher']),
{ label: tr('safezone.recenter'), hint: tr('safezone.recenterHint'), control: { type: 'switch', id: 'sz-recenter', on: recenterResets() } }],
};
}
// The sliders snap to whole percents (so the remembered values stay tidy).
function event(ev) {
if (ev.type === 'switch' && ev.value === 'sz-recenter') {
if (recenterResets()) ns.storage.remove(RECENTER_KEY); else ns.storage.set(RECENTER_KEY, 'on');
return true;
}
const id = typeof ev.value === 'string' && ev.value.startsWith('sz-') ? ev.value.slice(3) : null;
if (!IDS.includes(id)) return false;
if (ev.type === 'slider') SZ.set({ [id]: Math.round(valueAt(id, ev.fraction) * 100) / 100 });
@@ -49,5 +59,5 @@
return true;
}
ns.vrSafeZoneOptions = { content, event };
ns.vrSafeZoneOptions = { content, event, recenterResets, reset: () => ns.storage.remove(RECENTER_KEY) };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+226
View File
@@ -0,0 +1,226 @@
// A chat (canvas 2D), drawn by the Info style's 'chat' content (info.js): rows oldest first,
// each a round picture, the author's name (in a colour, or on a coloured chip, with small
// badges before it) and the message flowing after it on the same line, words and pictures
// (emojis) wrapping together; highlighted messages as coloured cards (an amount, a header
// line, a sticker); notices in grey after an info icon; and a pinned message in a banner
// above the list. Knows nothing about any site: colours come as token specs (tokens.js
// resolve), pictures as URLs.
//
// const chat = ns.vrStyleChat.create(g, kit); kit: a card kit (kit.js) for the pictures
// chat.layout(items, width) -> { rows: [{ item, y, h, lay }], height } rows laid out
// for a list `width` px wide (each item's layout is kept, by id and width)
// chat.draw(lay, x0, top, view) the rows, the list's top at `top`, left edge x0; only
// what's inside `view` ({ y, h })
// chat.bannerHeight(banner, width), chat.drawBanner(banner, x, y, width) the pinned
// message's box (0 tall for none)
// chat.drawInline(runs, x, midY, maxW, color) -> px drawn runs on one line (a message
// being typed), its end kept in view when it's longer than maxW
// chat.drawRank(rank, x, midY) -> px wide a rank pill ({ title: "#1", bg, fg } specs)
// item: { id, kind ('text' | 'card' | 'notice'), author, avatar, nameColor (spec), nameFill
// (spec: a chip behind the name), rank (a pill first: { title, bg, fg }, e.g. a top fan's
// "#1"), badges: [{ icon (symbols.js), color (spec) } | { image }], runs:
// [{ text } | { image, label }], card (kind 'card'): { color, onColor (specs), title
// (e.g. an amount), header (a line under it), sticker (a picture URL) } }
// banner: { header, item }
(function (ns) {
'use strict';
const T = ns.vrTokens, P = T.m3, S = ns.vrSymbols; // design tokens (tokens.js), icons
const { roundRect, FONT } = ns.vrKit;
const R = { avatar: 36, gap: 14, size: 21, line: 29, pad: 7, emoji: 26, badge: 22, nameGap: 10 };
const CARD = { pad: 14, head: 58, sticker: 76, radius: 14, margin: 6 };
const BANNER = { pad: 14, avatar: 28, lines: 3, header: 26 };
const spec = s => (s ? T.resolve(s) : null);
function create(g, kit) {
const cache = new Map(); // `${width}:${id}` -> an item's layout
// Lay the name, badges and message of `it` out in lines `maxW` wide: [[{ x, w, text |
// image | badge | name }]]. The name and badges start the first line; words and
// pictures follow, a word too long for a line broken between letters.
function flow(it, maxW, withName) {
const lines = [[]];
let x = 0;
const put = (piece, w) => {
if (x > 0 && x + w > maxW) { lines.push([]); x = 0; }
lines[lines.length - 1].push({ ...piece, x, w }); x += w;
};
if (withName) {
if (it.rank && it.rank.title) put({ rank: it.rank }, rankWidth(it.rank) + 6);
for (const b of it.badges || []) put({ badge: b }, R.badge + 6);
if (it.author) {
g.font = `500 ${R.size}px ${FONT}`;
const pad = it.nameFill ? 8 : 0;
put({ name: it.author }, g.measureText(it.author).width + pad * 2 + R.nameGap);
}
}
g.font = `${R.size}px ${FONT}`;
for (const r of it.runs || []) {
if (r.image) { put({ image: r.image, label: r.label }, R.emoji + 4); continue; }
for (const word of String(r.text).match(/\S+\s*|\s+/g) || []) {
if (!word.trim() && x === 0) continue; // no spaces at a line's start
const w = g.measureText(word).width;
if (w <= maxW) { put({ text: word }, w); continue; }
for (const ch of word) put({ text: ch }, g.measureText(ch).width);
}
}
return lines;
}
// One item's layout at `width`: { h, lines, textX, kind }.
function layoutItem(it, width) {
const key = `${width}:${it.id}`;
const hit = cache.get(key);
if (hit) return hit;
let lay;
if (it.kind === 'card') {
const c = it.card || {}, inner = width - CARD.pad * 2;
const lines = (it.runs || []).length ? flow(it, inner, false) : [];
const head = c.sticker ? CARD.sticker : CARD.head;
lay = { kind: 'card', lines, head, h: head + (lines.length ? lines.length * R.line + CARD.pad : 0) + CARD.margin * 2 };
} else {
const textX = R.avatar + R.gap, lines = flow(it, width - textX - 8, it.kind !== 'notice');
lay = { kind: it.kind, lines, textX, h: Math.max(R.avatar, lines.length * R.line) + R.pad * 2 };
}
if (cache.size > 800) cache.clear(); // (a long chat: start the cache over)
cache.set(key, lay);
return lay;
}
function layout(items, width) {
let y = 0;
const rows = (items || []).map(item => { const lay = layoutItem(item, width); const r = { item, y, h: lay.h, lay }; y += lay.h; return r; });
return { rows, height: y };
}
// The pieces of one laid-out line at (x, y) (the line's top), in `color`; the name in
// the item's colour (or on its chip).
function drawLine(it, line, x, y, color) {
g.textAlign = 'left'; g.textBaseline = 'middle';
const mid = y + R.line / 2;
for (const p of line) {
if (p.rank) { drawRank(p.rank, x + p.x, mid); continue; }
if (p.badge) {
const b = p.badge;
if (b.image) { const img = kit.image(b.image); if (img) kit.drawImageContain(img, x + p.x, mid - R.badge / 2, R.badge, R.badge); }
else if (b.icon) { g.fillStyle = spec(b.color) || P.onSurfaceVariant; S.draw(g, b.icon, x + p.x + R.badge / 2, mid, R.badge, true); }
} else if (p.name) {
g.font = `500 ${R.size}px ${FONT}`;
if (it.nameFill) {
g.fillStyle = spec(it.nameFill); roundRect(g, x + p.x, mid - 14, p.w - R.nameGap, 28, 14); g.fill();
g.fillStyle = spec(it.nameColor) || P.onSurface; g.fillText(p.name, x + p.x + 8, mid + 1);
} else { g.fillStyle = spec(it.nameColor) || P.onSurfaceVariant; g.fillText(p.name, x + p.x, mid + 1); }
} else if (p.image) {
const img = kit.image(p.image);
if (img) kit.drawImageContain(img, x + p.x, mid - R.emoji / 2, R.emoji, R.emoji);
else { g.fillStyle = T.m3Alpha('onSurface', 0.12); roundRect(g, x + p.x, mid - R.emoji / 2, R.emoji, R.emoji, 6); g.fill(); }
} else {
g.font = `${R.size}px ${FONT}`; g.fillStyle = color; g.fillText(p.text, x + p.x, mid + 1);
}
}
}
// A highlighted message: a rounded card in its colour; the header (picture, name, and the
// title or header line under it; a sticker on the right), then the message.
function drawCard(it, lay, x, y, w) {
const c = it.card || {}, top = y + CARD.margin, h = lay.h - CARD.margin * 2;
const on = spec(c.onColor) || P.onSurface;
g.fillStyle = spec(c.color) || P.surfaceContainerHighest; roundRect(g, x, top, w, h, CARD.radius); g.fill();
if (lay.lines.length) { // the message part a shade darker
g.save(); roundRect(g, x, top, w, h, CARD.radius); g.clip();
g.fillStyle = T.m3Alpha('shadow', 0.15); g.fillRect(x, top + lay.head, w, h - lay.head);
g.restore();
}
const ay = top + (lay.head - R.avatar) / 2;
kit.avatar(it.avatar, it.author, x + CARD.pad, ay, R.avatar);
const tx = x + CARD.pad + R.avatar + R.gap, tw = w - (tx - x) - CARD.pad - (c.sticker ? CARD.sticker : 0);
g.textAlign = 'left'; g.textBaseline = 'middle'; g.fillStyle = on;
g.font = `500 19px ${FONT}`; g.fillText(clip(it.author || '', tw), tx, top + lay.head / 2 - 12);
g.font = `700 20px ${FONT}`; g.fillText(clip([c.title, c.header].filter(Boolean).join(' · '), tw), tx, top + lay.head / 2 + 13);
if (c.sticker) {
const img = kit.image(c.sticker), s = CARD.sticker - 12;
if (img) kit.drawImageContain(img, x + w - CARD.pad - s, top + 6, s, s);
}
lay.lines.forEach((line, i) => drawLine(it, line, x + CARD.pad, top + lay.head + CARD.pad / 2 + i * R.line, on));
}
// Text cut with "…" to fit maxW (in the current font).
function clip(t, maxW) {
if (g.measureText(t).width <= maxW) return t;
while (t.length > 1 && g.measureText(t + '…').width > maxW) t = t.slice(0, -1);
return t + '…';
}
function draw(lay, x0, top, view) {
const width = view.w;
for (const r of lay.rows) {
const y = top + r.y;
if (y + r.h < view.y || y > view.y + view.h) continue; // off screen
const it = r.item;
if (r.lay.kind === 'card') { drawCard(it, r.lay, x0, y, width); continue; }
if (r.lay.kind === 'notice') { g.fillStyle = P.onSurfaceVariant; S.draw(g, 'info', x0 + R.avatar / 2, y + R.pad + R.line / 2, 26, false); }
else kit.avatar(it.avatar, it.author, x0, y + R.pad + (R.line - R.avatar) / 2, R.avatar);
const color = r.lay.kind === 'notice' ? P.onSurfaceVariant : P.onSurface;
r.lay.lines.forEach((line, i) => drawLine(it, line, x0 + r.lay.textX, y + R.pad + i * R.line, color));
}
}
// The pinned message: a header line, then the message (its first BANNER.lines lines) after
// a small picture, on a raised tile.
// (laid out as a row whose picture is BANNER.avatar wide, inside the tile's padding)
function bannerLayout(b, width) {
if (!b || !b.item) return null;
const lay = layoutItem({ ...b.item, id: `banner:${b.item.id}` }, width - BANNER.pad * 2 - (BANNER.avatar - R.avatar));
const lines = lay.lines.slice(0, BANNER.lines);
return { lay, lines, h: BANNER.pad * 2 + (b.header ? BANNER.header : 0) + Math.max(BANNER.avatar, lines.length * R.line) };
}
const bannerHeight = (b, width) => { const bl = bannerLayout(b, width); return bl ? bl.h : 0; };
function drawBanner(b, x, y, width) {
const bl = bannerLayout(b, width);
if (!bl) return 0;
g.fillStyle = P.surfaceContainerHigh; roundRect(g, x, y, width, bl.h, T.shape.medium); g.fill();
let ty = y + BANNER.pad;
if (b.header) {
g.fillStyle = P.onSurfaceVariant; g.font = `18px ${FONT}`; g.textAlign = 'left'; g.textBaseline = 'middle';
g.fillText(clip(b.header, width - BANNER.pad * 2), x + BANNER.pad, ty + BANNER.header / 2 - 2);
ty += BANNER.header;
}
kit.avatar(b.item.avatar, b.item.author, x + BANNER.pad, ty + (R.line - BANNER.avatar) / 2, BANNER.avatar);
const tx = x + BANNER.pad + BANNER.avatar + R.gap;
bl.lines.forEach((line, i) => drawLine(b.item, line, tx, ty + i * R.line, P.onSurface));
return bl.h;
}
// A rank pill: its title, bold, in its colours (the tertiary colour pair when it has none).
const RANK_FONT = `700 16px ${FONT}`, RANK_H = 24;
function rankWidth(rank) { g.font = RANK_FONT; return Math.ceil(g.measureText(rank.title).width) + 16; }
function drawRank(rank, x, midY) {
const w = rankWidth(rank);
g.fillStyle = spec(rank.bg) || P.tertiaryContainer; roundRect(g, x, midY - RANK_H / 2, w, RANK_H, RANK_H / 2); g.fill();
g.fillStyle = spec(rank.fg) || P.onTertiaryContainer; g.font = RANK_FONT; g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillText(rank.title, x + w / 2, midY + 1);
g.textAlign = 'left';
return w;
}
function drawInline(runs, x, midY, maxW, color) {
g.font = `${R.size}px ${FONT}`; g.textAlign = 'left'; g.textBaseline = 'middle';
const pieces = (runs || []).map(r => (r.image ? { image: r.image, w: R.emoji + 4 } : { text: r.text, w: g.measureText(r.text).width }));
const total = pieces.reduce((s, p) => s + p.w, 0);
g.save(); g.beginPath(); g.rect(x, midY - R.line, maxW, R.line * 2); g.clip();
let px = x - Math.max(0, total - maxW); // (longer than the room: its end shows)
for (const p of pieces) {
if (p.image) {
const img = kit.image(p.image);
if (img) kit.drawImageContain(img, px, midY - R.emoji / 2, R.emoji, R.emoji);
} else { g.fillStyle = color; g.fillText(p.text, px, midY + 1); }
px += p.w;
}
g.restore();
return Math.min(total, maxW);
}
return { layout, draw, bannerHeight, drawBanner, drawInline, drawRank };
}
ns.vrStyleChat = { create };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+175 -4
View File
@@ -10,6 +10,11 @@
// author (optionally highlighted, with a badge) · meta, text with clickable clock
// times, a footer (a site slot), and children (replies) opened under them;
// more load as you scroll (YouTube's Comments).
// 'chat': a live chat (chat.js): a pinned message in a banner, then the messages oldest
// first, following the newest (scrolled to the bottom) while `follow`; when not,
// a "jump to newest" button over the bottom of the list; optionally a row of
// reaction buttons (an emoji each) along the panel's bottom, with a Support
// button at its right end, and a message field with its Send button above it.
// 'message': a grey message in the middle (optionally under the loading spinner).
// Knows nothing about any site.
//
@@ -29,11 +34,20 @@
// threads: [{ comment: { id, author, avatar, highlight, badge, meta, text, childrenLabel },
// children: null | { open, items: [comments], loading, more } }],
// loading, done, error }
// { kind: 'chat', banner: null | { header, item }, items: [item] (chat.js; keep each
// item object while it's shown: its layout is kept by id), follow, labels: { newest, empty,
// send, support }, reactions: [{ id, emoji (a character), on (just sent: shown selected) }],
// support (show the Support button), input: null | { runs (what's typed: [{ text } |
// { image, label }]), placeholder, focused, canSend, note (a line instead of the field:
// why you can't chat), error (shown in the field's corner) }, xp: null | { label }
// (a 👑 pill at the right of the tab row), fans: null | { title, loading, error, empty,
// rows: [fan], you: fan | null } (a ranked list instead of the messages, with a back
// button; fan: { rank, name, avatar, points, badge ({ title, bg, fg } | null), note }) }
// { kind: 'message', text, spinner } }
// Control ids: 'tab:<id>', 'page:prev', 'page:next', 'row:<index>' (paged / rows),
// 'link:<index in links>', 'pill:<id>', 'time:<seconds>', 'children:<thread>',
// 'more:<thread>', 'text' (anywhere else in a scrolling area: a trigger drag can start
// there), plus the footer slot's own ids.
// 'more:<thread>', 'newest', 'react:<id>', 'field', 'send', 'support', 'fans', 'fansBack' (chat), 'text' (anywhere else in a scrolling area: a trigger
// drag can start there), plus the footer slot's own ids.
// Scrolling: scrollAt(u, v), maxScroll(), shownScroll() (after clamping / following),
// pxPerV. Also hitTest(u, v) -> { id } | null, has(id), imagesReady(), perPage.
(function (ns) {
@@ -279,6 +293,159 @@
endScroll();
}
// ------------------------------------------------------------ 'chat'
// The banner (if any) under the tabs, then the messages scrolling below it; following the
// newest, the list stays scrolled to its end. Its pictures (avatars, emojis: many) have
// their own kit, with room for more of them.
const chatKit = ns.vrKit.createCardKit(g, onImageLoaded, 400);
const chat = ns.vrStyleChat.create(g, chatKit);
const NEWEST = { h: 52, pad: 28 }; // the "jump to newest" button
const REACT = { size: 64, gap: 16, bar: 92 }; // the reaction buttons; the row's height
const INPUT = { h: 64, gap: 12, sendW: 132, btnH: 56 }; // the message field's row
// The bottom row: the reaction buttons (round tonal buttons, filled while `on`, the emoji
// drawn by the font) centred in it, and the Support button at its right end.
function drawBottomRow(list, labels, support, ui) {
const y = H - REACT.bar + (REACT.bar - REACT.size) / 2 - 6;
let right = W - PAD;
if (support) {
g.font = T.font('labelLarge', 22);
const w = g.measureText(labels.support || '').width + 28 + 8 + 48;
ns.vrKit.button(g, regions, 'support', right - w, y + (REACT.size - INPUT.btnH) / 2, w, INPUT.btnH, { icon: 'card_membership', label: labels.support, kind: 'tonal' }, ui);
right -= w + REACT.gap;
}
const total = list.length * REACT.size + Math.max(0, list.length - 1) * REACT.gap;
let x = Math.max(PAD, (PAD + right) / 2 - total / 2);
for (const r of list) {
ns.vrKit.button(g, regions, `react:${r.id}`, x, y, REACT.size, REACT.size, { kind: r.on ? 'filled' : 'tonal' }, ui);
g.font = `34px ${FONT}`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillStyle = P.onSurface;
g.fillText(r.emoji, x + REACT.size / 2, y + REACT.size / 2 + 2);
x += REACT.size + REACT.gap;
}
}
// The message row at y: a field (what's typed, with emoji pictures; the placeholder when
// empty; outlined in the primary colour with a caret while typing) and the Send button; or,
// when you can't chat, the note saying why.
function drawInput(inp, labels, y, ui) {
if (inp.note) {
g.fillStyle = P.onSurfaceVariant; g.font = `20px ${FONT}`; g.textAlign = 'left'; g.textBaseline = 'middle';
ns.vrKit.wrap(g, inp.note, W - PAD * 2, 2).forEach((line, i, all) => g.fillText(line, PAD, y + INPUT.h / 2 + (i - (all.length - 1) / 2) * 26));
return;
}
const fw = W - PAD * 2 - INPUT.sendW - INPUT.gap, r = INPUT.h / 2;
regions.push({ id: 'field', x: PAD, y, w: fw, h: INPUT.h });
g.fillStyle = P.surfaceContainerHighest; roundRect(g, PAD, y, fw, INPUT.h, r); g.fill();
ns.vrKit.hoverArea(g, 'field', ui, PAD, y, fw, INPUT.h, r);
if (inp.focused) { g.strokeStyle = P.primary; g.lineWidth = 3; roundRect(g, PAD + 1.5, y + 1.5, fw - 3, INPUT.h - 3, r - 1.5); g.stroke(); }
const tx = PAD + 24, room = fw - 48 - (inp.error ? 140 : 0), mid = y + INPUT.h / 2;
let end = tx;
if ((inp.runs || []).length) end = tx + chat.drawInline(inp.runs, tx, mid, room, P.onSurface);
else if (!inp.focused) {
g.fillStyle = P.onSurfaceVariant; g.font = `21px ${FONT}`; g.textAlign = 'left'; g.textBaseline = 'middle';
g.fillText(inp.placeholder || '', tx, mid + 1);
}
if (inp.focused) { g.fillStyle = P.primary; g.fillRect(end + 2, mid - 14, 3, 28); }
if (inp.error) {
g.fillStyle = P.error; g.font = `18px ${FONT}`; g.textAlign = 'right'; g.textBaseline = 'middle';
g.fillText(inp.error, PAD + fw - 24, mid + 1);
}
ns.vrKit.button(g, regions, 'send', W - PAD - INPUT.sendW, y + (INPUT.h - INPUT.btnH) / 2, INPUT.sendW, INPUT.btnH,
{ label: labels.send, kind: 'filled', enabled: !!inp.canSend }, ui);
}
// The XP pill at the right end of the tab row (its width: xpWidth, also used to keep the
// tabs clear of it).
const XP = { h: 48, y: 18 };
const xpLabel = xp => `👑 ${xp.label}`;
function xpWidth(c) {
if (!c || c.kind !== 'chat' || !c.xp) return 0;
g.font = T.font('labelLarge', 22); return Math.ceil(g.measureText(xpLabel(c.xp)).width) + 40;
}
// Top fans: a back button and the title, the ranked rows scrolling (rank or its badge, picture,
// name, XP), and your own row pinned under them with YouTube's line about it.
const FAN = { h: 64, avatar: 40, rankW: 70, head: 64 };
function drawFanRow(f, x, y, w, you) {
const mid = y + FAN.h / 2;
if (f.badge) chat.drawRank(f.badge, x + 4, mid);
else { g.fillStyle = P.onSurfaceVariant; g.font = `500 20px ${FONT}`; g.textAlign = 'left'; g.textBaseline = 'middle'; g.fillText(f.rank || '–', x + 8, mid + 1); }
kit.avatar(f.avatar, f.name, x + FAN.rankW, mid - FAN.avatar / 2, FAN.avatar);
g.font = `500 21px ${FONT}`; g.textAlign = 'right'; g.textBaseline = 'middle'; g.fillStyle = you ? P.primary : P.onSurface;
g.fillText(f.points || '', x + w - 8, mid + 1);
const pw = g.measureText(f.points || '').width, nx = x + FAN.rankW + FAN.avatar + 14;
g.textAlign = 'left'; g.font = `${you ? 500 : 400} 21px ${FONT}`; g.fillStyle = P.onSurface;
const room = x + w - 8 - pw - 16 - nx;
let name = f.name || '';
while (name.length > 1 && g.measureText(name).width > room) name = name.slice(0, -2) + '…';
g.fillText(name, nx, mid + 1);
}
function drawFans(fans, labels, m, ui) {
const top = TOP;
ns.vrKit.button(g, regions, 'fansBack', PAD - 6, top + 4, 52, 52, { icon: 'arrow_back' }, ui);
g.fillStyle = P.onSurface; g.font = `500 24px ${FONT}`; g.textAlign = 'left'; g.textBaseline = 'middle';
g.fillText(fans.title || '', PAD + 60, top + 30);
// Your row at the bottom: a divider, the row, then YouTube's line (up to two lines).
let bottom = H - 20;
if (fans.you) {
g.font = `18px ${FONT}`;
const note = fans.you.note ? ns.vrKit.wrap(g, fans.you.note, W - PAD * 2, 2) : [];
const youH = FAN.h + note.length * 24 + (note.length ? 8 : 0);
bottom = H - 20 - youH;
g.fillStyle = P.outlineVariant; g.fillRect(PAD, bottom - 6, W - PAD * 2, 2);
drawFanRow(fans.you, PAD, bottom, W - PAD * 2, true);
g.fillStyle = P.onSurfaceVariant; g.font = `18px ${FONT}`; g.textAlign = 'left'; g.textBaseline = 'top';
note.forEach((line, i) => g.fillText(line, PAD, bottom + FAN.h + i * 24));
bottom -= 14;
}
const view = { x: VIEW.x, y: top + FAN.head, w: VIEW.w, h: bottom - top - FAN.head };
const rows = fans.rows || [];
if (!rows.length) {
const cy = view.y + view.h / 2;
if (fans.loading) ns.vrGlyphs.spinner(g, W / 2, cy - 20, 26, m.now || 0, false);
g.fillStyle = P.onSurfaceVariant; g.font = `22px ${FONT}`; g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillText(fans.loading ? '' : fans.error || fans.empty || '', W / 2, cy + 20);
return;
}
beginScroll(m, rows.length * FAN.h + 8, null, view);
g.save(); g.beginPath(); g.rect(view.x - 8, view.y, view.w + 16, view.h); g.clip();
rows.forEach((f, i) => {
const y = view.y - scrollPx + i * FAN.h;
if (y + FAN.h >= view.y && y <= view.y + view.h) drawFanRow(f, view.x, y, view.w, false);
});
g.restore();
endScroll();
}
function drawChat(c, m, ui) {
const reactions = c.reactions || [], labels = c.labels || {};
if (c.xp) { // your XP, opening Top fans
const w = xpWidth(c);
ns.vrKit.button(g, regions, 'fans', W - PAD - w, XP.y, w, XP.h, { label: xpLabel(c.xp), kind: c.fans ? 'filled' : 'tonal' }, ui);
}
if (c.fans) { drawFans(c.fans, labels, m, ui); return; }
let bottom = H - 20; // where the list ends: above the bottom rows
if (reactions.length || c.support) { drawBottomRow(reactions, labels, !!c.support, ui); bottom = H - REACT.bar; }
if (c.input) { drawInput(c.input, labels, bottom - INPUT.h - 6, ui); bottom -= INPUT.h + 18; }
const bannerH = chat.bannerHeight(c.banner, VIEW.w);
if (bannerH) chat.drawBanner(c.banner, VIEW.x, TOP, VIEW.w);
const top = TOP + (bannerH ? bannerH + 10 : 0);
const view = { x: VIEW.x, y: top, w: VIEW.w, h: bottom - top };
if (!(c.items || []).length) { message((c.labels || {}).empty || ''); return; }
const lay = chat.layout(c.items, view.w);
beginScroll(m, lay.height + 8, c.follow ? lay.height : null, view);
g.save(); g.beginPath(); g.rect(view.x - 8, view.y, view.w + 16, view.h); g.clip();
chat.draw(lay, view.x, view.y - scrollPx, view);
g.restore();
if (!c.follow) { // scrolled back: a way to the newest
const label = (c.labels || {}).newest || '';
g.font = T.font('labelLarge', 22);
const w = g.measureText(label).width + 28 + 8 + NEWEST.pad * 2;
ns.vrKit.button(g, regions, 'newest', W / 2 - w / 2, view.y + view.h - NEWEST.h - 16, w, NEWEST.h, { icon: 'expand_more', label, kind: 'filled' }, ui);
}
endScroll();
}
// ------------------------------------------------------------ scrolling
// Start drawing a scrolling area (`view`, VIEW unless given) with `contentH` of content:
// clamp the scroll (or use `want`, e.g. the rows following their current one) and
@@ -313,12 +480,15 @@
// The tabs end before the page count and arrows when the content is paged.
const pc = m.content && m.content.kind === 'paged' && m.content.pageCount > 1 ? m.content : null;
g.font = `22px ${FONT}`;
headerTabs(g, m.tabs, m.tab, ui, regions, PAD, { maxX: pc ? ARROWS.prev.x - 32 - g.measureText(`${pc.page + 1} / ${pc.pageCount}`).width : W - PAD });
const xpW = xpWidth(m.content); // (a chat's XP pill at the right of the tabs)
g.font = `22px ${FONT}`;
headerTabs(g, m.tabs, m.tab, ui, regions, PAD, { maxX: pc ? ARROWS.prev.x - 32 - g.measureText(`${pc.page + 1} / ${pc.pageCount}`).width : W - PAD - (xpW ? xpW + 16 : 0) });
const c = m.content || { kind: 'message', text: '' };
if (c.kind === 'paged') drawPaged(c, ui);
else if (c.kind === 'text') drawText(c, m, ui);
else if (c.kind === 'rows') drawRows(c, m, ui);
else if (c.kind === 'thread') drawThread(c, m, ui);
else if (c.kind === 'chat') drawChat(c, m, ui);
else message(c.text || '', c.spinner ? m.now || 0 : null);
}
@@ -328,7 +498,8 @@
const scrollAt = (u, v) => scrollable && u * W >= curView.x - 8 && u * W <= W - 16 && v * H >= curView.y && v * H <= curView.y + curView.h;
return {
canvas, aspect: H / W, draw, hitTest, has, scrollAt, imagesReady: kit.imagesReady, perPage: PER_PAGE, animating: motion.moving,
canvas, aspect: H / W, draw, hitTest, has, scrollAt, perPage: PER_PAGE, animating: motion.moving,
imagesReady: () => kit.imagesReady() + chatKit.imagesReady(),
maxScroll: () => maxScrollPx, // how far the last drawn tab can scroll (px)
shownScroll: () => scrollPx, // where it actually is (after clamping / following)
pxPerV: H, // canvas pixels per unit of v (for drag scrolling)
+6 -4
View File
@@ -90,13 +90,15 @@
const cardHeight = (thumbH, lines = 2) => thumbH + 60 + 25 * lines;
// Images and cards drawing on the 2D context `g`. onImageLoaded(): called when an image
// finishes loading, so the caller redraws. Returns { image(url), imagesReady(),
// finishes loading, so the caller redraws; limit: how many images to keep (default 160).
// Returns { image(url), imagesReady(),
// drawImageCover(img, x, y, w, h), drawImageContain(img, x, y, w, h), avatar(url, name, x, y, s),
// drawCard(card, x, y, box, ui, regions, id) }.
function createCardKit(g, onImageLoaded) {
function createCardKit(g, onImageLoaded, limit = 160) {
// Images (thumbnails, avatars, banners): loaded with CORS so the canvas stays usable as
// a WebGL texture. Returns the image once loaded, else null (and starts loading it). If
// the image server refuses, callers draw a plain placeholder.
// the image server refuses, callers draw a plain placeholder. At most `limit` are kept
// (the oldest go first): enough for everything one panel shows at once.
const images = new Map(); // url -> { img, ok }
function image(url) {
if (!url) return null;
@@ -108,7 +110,7 @@
t.img.onerror = () => { t.ok = false; };
t.img.src = url;
images.set(url, t);
if (images.size > 160) images.delete(images.keys().next().value); // keep the cache small
if (images.size > limit) images.delete(images.keys().next().value); // keep the cache small
}
return t.ok ? t.img : null;
}
+9
View File
@@ -56,12 +56,21 @@
arrow_back: [
'm313-440 196 196q12 12 11.5 28T508-188q-12 11-28 11.5T452-188L188-452q-6-6-8.5-13t-2.5-15q0-8 2.5-15t8.5-13l264-264q11-11 27.5-11t28.5 11q12 12 12 28.5T508-715L313-520h447q17 0 28.5 11.5T800-480q0 17-11.5 28.5T760-440H313Z',
'm313-440 196 196q12 12 11.5 28T508-188q-12 11-28 11.5T452-188L188-452q-6-6-8.5-13t-2.5-15q0-8 2.5-15t8.5-13l264-264q11-11 27.5-11t28.5 11q12 12 12 28.5T508-715L313-520h447q17 0 28.5 11.5T800-480q0 17-11.5 28.5T760-440H313Z'],
back_hand: [
'M512-40q-82 0-154-37.5T240-182L63-442q-7-10-5.5-21.5T67-483q20-21 49.5-24t53.5 14l110 76v-383q0-17 11.5-28.5T320-840q17 0 28.5 11.5T360-800v460q0 24-21.5 35.5T297-307l-85-59 95 138q35 51 89 79.5T512-120q103 0 175.5-72.5T760-368v-392q0-17 11.5-28.5T800-800q17 0 28.5 11.5T840-760v392q0 137-95.5 232.5T512-40Zm-32-880q17 0 28.5 11.5T520-880v360q0 17-11.5 28.5T480-480q-17 0-28.5-11.5T440-520v-360q0-17 11.5-28.5T480-920Zm160 40q17 0 28.5 11.5T680-840v320q0 17-11.5 28.5T640-480q-17 0-28.5-11.5T600-520v-320q0-17 11.5-28.5T640-880ZM486-300Z',
'M512-40q-82 0-154-37.5T240-182L63-442q-7-10-5.5-21.5T67-483q20-21 49.5-24t53.5 14l110 76v-383q0-17 11.5-28.5T320-840q17 0 28.5 11.5T360-800v320h80v-400q0-17 11.5-28.5T480-920q17 0 28.5 11.5T520-880v400h80v-360q0-17 11.5-28.5T640-880q17 0 28.5 11.5T680-840v360h80v-280q0-17 11.5-28.5T800-800q17 0 28.5 11.5T840-760v392q0 137-95.5 232.5T512-40Z'],
backspace: [
'M360-200q-20 0-37.5-9T294-234L153-434q-15-21-15-46t15-46l141-200q11-16 28.5-25t37.5-9h400q33 0 56.5 23.5T840-680v400q0 33-23.5 56.5T760-200H360Zm400-80v-400 400Zm-400 0h400v-400H360L218-480l142 200Zm200-144 76 76q11 11 28 11t28-11q11-11 11-28t-11-28l-76-76 76-76q11-11 11-28t-11-28q-11-11-28-11t-28 11l-76 76-76-76q-11-11-28-11t-28 11q-11 11-11 28t11 28l76 76-76 76q-11 11-11 28t11 28q11 11 28 11t28-11l76-76Z',
'M360-200q-20 0-37.5-9T294-234L153-434q-15-21-15-46t15-46l141-200q11-16 28.5-25t37.5-9h400q33 0 56.5 23.5T840-680v400q0 33-23.5 56.5T760-200H360Zm200-224 76 76q11 11 28 11t28-11q11-11 11-28t-11-28l-76-76 76-76q11-11 11-28t-11-28q-11-11-28-11t-28 11l-76 76-76-76q-11-11-28-11t-28 11q-11 11-11 28t11 28l76 76-76 76q-11 11-11 28t11 28q11 11 28 11t28-11l76-76Z'],
bookmark: [
'm480-240-168 72q-40 17-76-6.5T200-241v-519q0-33 23.5-56.5T280-840h400q33 0 56.5 23.5T760-760v519q0 43-36 66.5t-76 6.5l-168-72Zm0-88 200 86v-518H280v518l200-86Zm0-432H280h400-200Z',
'm480-240-168 72q-40 17-76-6.5T200-241v-519q0-33 23.5-56.5T280-840h400q33 0 56.5 23.5T760-760v519q0 43-36 66.5t-76 6.5l-168-72Z'],
bottom_panel_close: [
'm508-528 98-98q10-10 5-22t-19-12H368q-14 0-19 12t5 22l98 98q12 12 28 12t28-12Zm252-312q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560ZM200-320v120h560v-120H200Zm560-80v-360H200v360h560Zm-560 80v120-120Z',
'm508-528 98-98q10-10 5-22t-19-12H368q-14 0-19 12t5 22l98 98q12 12 28 12t28-12Zm252-312q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560Zm0 440v-360H200v360h560Z'],
bottom_panel_open: [
'M368-500h224q14 0 19-12t-5-22l-98-98q-12-12-28-12t-28 12l-98 98q-10 10-5 22t19 12ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-200v120h560v-120H200Zm0-80h560v-360H200v360Zm0 80v120-120Z',
'M368-500h224q14 0 19-12t-5-22l-98-98q-12-12-28-12t-28 12l-98 98q-10 10-5 22t19 12ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-280h560v-360H200v360Z'],
closed_caption_disabled: [
'm361-600 60 60H300v120h80q0-9 6-15t15-6h18q9 0 15 6t6 15v20q0 17-11.5 28.5T400-360H280q-17 0-28.5-11.5T240-400v-160q0-17 11.5-28.5T280-600h81Zm479-120v400q0 20-13 30.5T799-279q-15 0-27-10.5T760-320v-400H359q-20 0-30-12.5T319-760q0-15 10-27.5t30-12.5h401q33 0 56.5 23.5T840-720ZM720-400q0 9-3.5 17.5T706-369l-51-51h5q0-9 6-15t15-6h18q9 0 15 6t6 15v20Zm-40-200q17 0 28.5 11.5T720-560v20q0 9-6 15t-15 6h-18q-9 0-15-6t-6-15h-80v45l-60-60v-5q0-17 11.5-28.5T560-600h120Zm-122 82Zm-154 74ZM200-160q-33 0-56.5-23.5T120-240v-480q0-25 13.5-44.5T168-793l73 73h-41v480h407L55-791q-12-12-12-28.5T55-848q12-12 28.5-12t28.5 12l735 735q12 12 12 28.5T847-56q-12 12-28.5 12T790-56L687-160H200Z',
'M791-57 687-160H200q-33 0-56.5-23.5T120-240v-487l-65-65q-12-12-12-28.5T55-849q12-12 28.5-12t28.5 12l736 736q12 12 12 28t-12 28q-12 12-28.5 12T791-57ZM280-360h120q17 0 28.5-11.5T440-400v-25q0-8-6-14t-14-6h-20q-8 0-14 6t-6 14v5h-80v-127l-45-45v1q-7 5-11 13t-4 18v160q0 17 11.5 28.5T280-360Zm560-360v388q0 27-24.5 37.5T772-303l-66-66q7-5 10.5-13.5T720-400v-20q0-8-6-14t-14-6h-20q-8 0-14 6t-6 14h-5l-75-75v-45h80v5q0 8 6 14t14 6h20q8 0 14-6t6-14v-25q0-17-11.5-28.5T680-600H560q-17 0-28.5 11.5T520-560v5L343-732q-19-19-8.5-43.5T372-800h388q33 0 56.5 23.5T840-720Z'],
+162 -55
View File
@@ -19,7 +19,7 @@
// whatever the site's panels use,
// state() -> { title, time, duration, paused,
// nowPlaying (what the controls panel shows: see panel-ui.js),
// quality: { current, currentLabel, levels: [{ id, label }] },
// quality: { current, currentLabel, levels: [{ id, label, icons? }] },
// live: null | { atHead, behind (seconds), dvr } (a live stream; time
// and duration then cover only the rewindable part),
// speed, speeds: [rates], volume: { level, muted },
@@ -128,7 +128,7 @@
isOpen: () => isOpen(reg.id),
closeMenu: () => { menu = null; },
confirm: d => confirm(d),
typeInto: on => typeInto(p, on),
typeInto: (on, opts) => typeInto(p, on, opts),
});
panels.set(reg.id, p);
}
@@ -146,12 +146,18 @@
// (Typing into the top panel's bar needs only the panels shown: the bar is always there.)
const typingNow = () => !!typing && (typing.zone === 'top' ? visible : shown(typing));
// A panel asks for the keys (on) or gives them back. Asking also brings up the in-VR
// keyboard if it isn't up; giving them back puts it away again if it came up that way.
// keyboard if it isn't up; giving them back (with no other panel typing) puts it away again
// if it came up that way. A panel asking while another has the keys takes them over, and
// the other is sent Escape (as for a click on nothing), so it stops typing too (its caret
// goes). opts.emojis: the panel's own emojis for the keyboard's emoji pages (keyboard.js setEmojis).
let kbAuto = false; // the keyboard came up because a panel asked for keys
function typeInto(p, on) {
function typeInto(p, on, opts = {}) {
const before = typing;
typing = on ? p : (typing === p ? null : typing);
if (on && before && before !== p) before.ctrl.event({ type: 'key', value: 'Escape' }); // (its giving back changes nothing now)
if (on || !typing) kb.setEmojis(on ? opts.emojis || [] : []);
if (on && !kbShown) { showKeyboard(true); kbAuto = true; }
else if (!on && kbAuto && kbShown) { showKeyboard(false); kbAuto = false; }
else if (!on && !typing && kbAuto && kbShown) { showKeyboard(false); kbAuto = false; }
}
const isTypingKey = e => !e.ctrlKey && !e.metaKey && !e.altKey && (e.key.length === 1 || TYPING_KEYS.test(e.key));
function onKey(e) {
@@ -243,7 +249,8 @@
for (const side of ['left', 'right']) {
const list = [...panels.values()].filter(p => p.zone === side);
if (!list.length) continue;
const tb = ns.vrOrbitersUI.createToolbar(list.map(itemOf), side);
// (with a grab bar on its video side, 'sgrab:<side>': gripping it moves the whole side, moveSide)
const tb = ns.vrOrbitersUI.createToolbar(list.map(itemOf), side, { handle: { id: `sgrab:${side}` } });
orbiters[side] = { ui: tb, pass: ns.vrPanelPass.createPanelPass(gl, vaos, tb.canvas), size: metres(tb.canvas), key: '' };
}
const popupPanel = [...panels.values()].find(p => p.zone === 'controls') || null;
@@ -283,8 +290,43 @@
if (z === 'top') return; // its controller decides (model().open)
open[z] = open[z] === which ? null : which;
if (OPEN_KEY[z]) ns.storage.set(OPEN_KEY[z], open[z] || 'none');
replaced[z] = undefined; // (your choice now: nothing to bring back)
}
// Shown: open while the panels are up, or open and registered to stay while they're hidden.
const shown = p => isOpen(p.reg.id) && (visible || !!p.reg.stays);
// A side panel offered only some of the time (its registration's `available(st)`, asked
// every frame; e.g. a live stream's chat): while it isn't, its toolbar item is hidden and it
// can't be open. One registered `autoOpen` opens whenever it becomes available (not
// remembered); the panel it replaced comes back when it goes (else the side's default).
const replaced = { left: undefined, right: undefined }; // per side: the panel an autoOpen one replaced
const availableNow = p => p.available !== false;
const fallbackOf = side => { const d = [...panels.values()].find(p => p.zone === side && p.reg.default && availableNow(p)); return d ? d.reg.id : null; };
function refreshAvailable(st) {
let changed = false;
for (const p of panels.values()) {
if (!p.reg.available || (p.zone !== 'left' && p.zone !== 'right')) continue;
let a = false;
try { a = !!p.reg.available(st); } catch (_) { /* treated as unavailable */ }
if (a === p.available) continue;
p.available = a; changed = true;
const z = p.zone, id = p.reg.id;
if (a && p.reg.autoOpen && open[z] !== id) { replaced[z] = open[z]; open[z] = id; }
else if (!a && open[z] === id) {
const back = replaced[z];
open[z] = back === null || (back && back !== id && availableNow(panels.get(back))) ? back : fallbackOf(z);
replaced[z] = undefined;
}
}
if (!changed) return;
for (const side of ['left', 'right']) {
const o = orbiters[side];
if (!o) continue;
const hidden = {};
for (const p of panels.values()) if (p.zone === side && !availableNow(p)) hidden[`toggle:${p.reg.id}`] = true;
if (o.ui.setHidden(hidden)) { laidOut = -1; o.key = ''; } // the bar's length changed: lay it out again (a moved side stays moved)
}
}
const shown = p => visible && isOpen(p.reg.id);
// ------------------------------------------------------------ the keyboard
// keyboard.js's keys, standing in for the controls panel: while it's up (toggleKeyboard,
@@ -343,6 +385,7 @@
let model = M.identity(); // the controls panel's pose in the world (centre; +Z faces you)
let visible = cfg.showOnStart; // are the panels shown (toggled by the left D-pad, auto-hide)
let lastActivity = 0; // when the laser last touched them (for auto-hide)
let dismissedAt = -1; // when a trigger press on nothing hid everything (-1: not hidden so; see update)
const hands = new Map(); // handedness -> { hover, trigger, grip, beam, scrollPanel, pagePanel }
let pressed = null; // { hand, id } while the trigger is held on a control
let scrub = null; // { hand, time } while dragging on the seek bar
@@ -351,6 +394,7 @@
let textDrag = null; // { hand, p, v, scroll, moved, last, at, vel } while a trigger drags a scrolling area
let fling = null; // { p, pos, vel (px/s) } while a let-go drag keeps scrolling (stepFling)
let grab = null; // { hand, local } while moving the panel by its grab bar
let sideGrab = null; // { hand, side, local, at } while moving a side by its toolbar's handle (moveSide)
let menu = null; // null | 'quality' | 'speed' | 'format' | 'volume' | 'x:<id>' (a site button's)
let extrasNow = null; // the site's own buttons, from the latest state (st.extras)
let menuDrag = null; // { hand, v, scroll, moved } while a trigger drags a scrolling menu
@@ -387,14 +431,23 @@
const sides = { left: null, right: null }; // per side: { bar (the toolbar's pose), barYaw }
let laidOut = -1; // the zone version they were laid out for
let searchBarModel = M.identity(); // the search bar's pose (with a top panel)
// What you moved by a grab bar: the move (a rotation and translation) applied on top of
// the laid-out pose, for the controls panel (with the keyboard, the pill and its popup) and
// for each side (its toolbar and every panel on it together). Laying out again keeps them;
// a recenter carries them along (place); only the global reset drops them (resetSettings).
const sideMove = { left: null, right: null };
let controlsMove = null, laidControls = M.identity(); // the controls panel's move; its laid-out pose
const moved = (side, m) => (sideMove[side] ? M.multiply(sideMove[side], m) : m);
function relayout() {
const Z = ns.vrSafeZone, z = Z.angles(), eye = Z.anchor.pos;
faceToward(Z.point(0, z.bottom - GAP - halfDeg(size[1]), D), eye);
laidControls = facing(Z.point(0, z.bottom - GAP - halfDeg(size[1]), D), eye);
model = controlsMove ? M.multiply(controlsMove, laidControls) : laidControls;
for (const side of ['left', 'right']) {
const o = orbiters[side];
if (!o) continue;
const sgn = side === 'left' ? -1 : 1, edge = side === 'left' ? z.left : z.right;
const barYaw = edge + sgn * (GAP / 2 + halfDeg(o.ui.barWidth * MPX));
// (out by the grab bar's room too: it stands between the zone's edge and the toolbar)
const barYaw = edge + sgn * (GAP / 2 + halfDeg(o.ui.barWidth * MPX) + 2 * halfDeg((o.ui.handleRoom || 0) * MPX));
// The toolbar's canvas, moved so its bar (not the canvas's centre) is at barYaw.
const shift = M.translation(-(o.ui.barCentre.x - o.ui.canvas.width / 2) * MPX, (o.ui.barCentre.y - o.ui.canvas.height / 2) * MPX, 0);
sides[side] = { bar: M.multiply(facing(Z.point(barYaw, z.mid, D - dp(16)), eye), shift), barYaw, pitch: z.mid };
@@ -441,25 +494,45 @@
const pillButtonH = () => orbiters.pill.ui.buttonHeight * MPX;
const pillModel = () => M.multiply(model, M.translation(0, size[1] / 2 - dp(12) + pillButtonH() / 2, dp(16)));
const popupModel = p => M.multiply(model, M.translation(0, size[1] / 2 - dp(12) + pillButtonH() + dp(8) + p.size[1] / 2, dp(32)));
// Where a panel is: on its side, or the popup.
const panelModel = p => (p.zone === 'controls' ? popupModel(p) : p.pose);
// Where an orbiter is: a side's toolbar, the pill or the search bar.
const orbiterModel = name => (name === 'pill' ? pillModel() : name === 'bar' ? searchBarModel : sides[name].bar);
// Where a panel is: on its side (as moved), or the popup.
const panelModel = p => (p.zone === 'controls' ? popupModel(p) : p.zone === 'left' || p.zone === 'right' ? moved(p.zone, p.pose) : p.pose);
// Where an orbiter is: a side's toolbar (as moved), the pill or the search bar.
const orbiterModel = name => (name === 'pill' ? pillModel() : name === 'bar' ? searchBarModel : moved(name, sides[name].bar));
// Move a side by its toolbar's handle: the point grabbed (on the handle) follows the hand
// and the side turns to face you there; its toolbar and every panel on it come along.
// (g.local: the grabbed point in the hand's space; g.at: the same point in the toolbar's
// laid-out space, so the side turns about it.)
function moveSide(g, point, head) {
const s = sides[g.side];
if (!s) return;
const held = M.multiply(facing(point, head), M.translation(-g.at[0], -g.at[1], -g.at[2])); // where the toolbar goes
sideMove[g.side] = M.multiply(held, M.invertRigid(s.bar));
}
// Lay everything out in front of you: on entering VR and whenever you recenter. `yaw`
// is the direction you're facing; the Safe Zone is measured from here.
// is the direction you're facing; the Safe Zone is measured from here. What you moved
// (the controls panel, the sides) comes along: its move is turned and shifted as the
// recenter turned and shifted the layout, so it stays where it was relative to you. (With
// Settings → Safe zone → "Recenter resets panels" on, the moves are dropped instead.)
const anchorFrame = () => { const a = ns.vrSafeZone.anchor; return M.multiply(M.translation(a.pos[0], a.pos[1], a.pos[2]), M.rotationY(a.yaw)); };
function place(head, yaw) {
const before = anchorFrame();
ns.vrSafeZone.setAnchor(head, yaw);
const turn = M.multiply(anchorFrame(), M.invertRigid(before)); // the old anchor's frame -> the new one's
const drop = ns.vrSafeZoneOptions && ns.vrSafeZoneOptions.recenterResets();
const carry = mv => (mv && !drop ? M.multiply(turn, M.multiply(mv, M.invertRigid(turn))) : null);
sideMove.left = carry(sideMove.left); sideMove.right = carry(sideMove.right); controlsMove = carry(controlsMove);
relayout();
lastActivity = performance.now(); // don't auto-hide right after placing
}
// The global reset: the keyboard back to its default size (forgetting yours), any move
// or resize dropped, and the panels shown. Their place comes from the recenter that
// follows (the controls panel has no remembered place: the layout puts it back).
// or resize dropped (the controls panel and the sides back in the layout's places), and
// the panels shown. Their place comes from the recenter that follows.
function resetSettings() {
kbScale = kbCfg.scale; ns.storage.remove(KB_SCALE_KEY);
kbGrab = null; grab = null; menu = null;
kbGrab = null; grab = null; sideGrab = null; menu = null;
controlsMove = null; sideMove.left = sideMove.right = null;
visible = true;
lastActivity = performance.now();
}
@@ -469,24 +542,27 @@
function toggleVisible() {
if (kbShown) { showKeyboard(false); return; }
visible = !visible;
if (!visible) { menu = null; grab = null; }
if (!visible) { menu = null; grab = null; sideGrab = null; }
lastActivity = performance.now();
}
// Grip belongs to us (not recenter) while that hand points at or holds something grabbable.
const isSideGrab = id => !!id && id.startsWith('sgrab:');
const claimsGrip = hand => {
const h = hands.get(hand);
return !!(grab && grab.hand === hand) || !!(targetGrab && targetGrab.hand === hand) || !!(kbGrab && kbGrab.hand === hand) ||
!!(h && ((visible && h.hover === 'grab') || h.targetHit || (kbShown && isKbHandle(h.hover))));
return !!(grab && grab.hand === hand) || !!(sideGrab && sideGrab.hand === hand) || !!(targetGrab && targetGrab.hand === hand) || !!(kbGrab && kbGrab.hand === hand) ||
!!(h && ((visible && (h.hover === 'grab' || isSideGrab(h.hover))) || h.targetHit || (kbShown && isKbHandle(h.hover))));
};
// The stick belongs to us (scrolling, not ±10 s) while that hand points at a scrolling
// area; iconSwaps() tells the controller models to show the scroll icon there.
const stickScrolls = hand => { const h = hands.get(hand); return !!(visible && h && (h.scrollPanel || h.menuScroll)); };
// area; iconSwaps() tells the controller models to show the scroll icon there. (A panel
// that stays while the rest is hidden, e.g. a live chat, counts then too.)
const reachable = p => !!p && (visible || !!p.reg.stays);
const stickScrolls = hand => { const h = hands.get(hand); return !!(h && (reachable(h.scrollPanel) || (visible && h.menuScroll))); };
// Likewise its left / right turns pages while it points at a panel showing page arrows
// (h.pagePanel, set in update). stickPage(hand, dir) presses that panel's previous
// (dir < 0) / next arrow if it's enabled; true if the stick was ours.
const stickPages = hand => { const h = hands.get(hand); return !!(visible && h && h.pagePanel); };
const stickPages = hand => { const h = hands.get(hand); return !!(h && reachable(h.pagePanel)); };
function stickPage(hand, dir) {
if (!stickPages(hand)) return false;
const p = hands.get(hand).pagePanel, id = dir < 0 ? 'page:prev' : 'page:next';
@@ -500,6 +576,10 @@
else if (stickPages(hand)) out[hand] = { seek: 'page' };
// the keyboard button shows it puts the keyboard away while it's up
if (kbShown) out[hand] = { ...(out[hand] || {}), keyboard: 'keyboardhide' };
// the grip shows a hand while it would grab (or is holding) something instead of recentering
if (claimsGrip(hand)) out[hand] = { ...(out[hand] || {}), recenter: 'grab' };
// the panels button: "close" while they're up, "open" while they're hidden
if (!visible) out[hand] = { ...(out[hand] || {}), panel: 'panelopen' };
}
return out;
}
@@ -663,6 +743,7 @@
extrasNow = st.extras || null;
const headPos = [head.x, head.y, head.z];
const frameInfo = { st, now };
refreshAvailable(st); // (before the ticks: a panel just gone is closed)
for (const p of panels.values()) if (p.ctrl.tick) p.ctrl.tick(frameInfo);
lastHead = headPos;
@@ -692,6 +773,7 @@
if (menuDrag && menuDrag.hand === hand) menuDrag = null;
if (textDrag && textDrag.hand === hand) textDrag = null;
if (grab && grab.hand === hand) grab = null;
if (sideGrab && sideGrab.hand === hand) sideGrab = null;
if (targetGrab && targetGrab.hand === hand) { targetGrab.target.grabEnd(headPos); targetGrab = null; }
if (h.smoother) h.smoother.reset(); // tracking lost: start afresh, don't glide from the old pose
continue;
@@ -706,10 +788,16 @@
// Moving the panel: it keeps its offset from the controller and turns to face you.
if (grab && grab.hand === hand) {
if (grip) faceToward(M.transformPoint(m, grab.local), headPos);
if (grip) { faceToward(M.transformPoint(m, grab.local), headPos); controlsMove = M.multiply(model, M.invertRigid(laidControls)); }
else { grab = null; buzz(src, 0.3, 15); }
lastActivity = now;
}
// Moving a side by its grab bar: the same, for the side's open panel and everything on it.
if (sideGrab && sideGrab.hand === hand) {
if (grip) moveSide(sideGrab, M.transformPoint(m, sideGrab.local), headPos);
else { sideGrab = null; buzz(src, 0.3, 15); }
lastActivity = now;
}
// Dragging another target (e.g. resizing or moving the cinema screen).
if (targetGrab && targetGrab.hand === hand) {
if (grip) targetGrab.target.grabMove(m, origin, dir, headPos);
@@ -822,6 +910,13 @@
} else if (grip && !h.grip && hover === 'grab' && !grab) {
grab = { hand, local: toLocal(m, [model[12], model[13], model[14]]) };
buzz(src, 0.5, 25);
} else if (grip && !h.grip && isSideGrab(hover) && !sideGrab && surface && surface.startsWith('orbit:')) {
// A side's move handle (on its toolbar): the point grabbed held where it is relative to the hand.
const side = hover.slice(6), point = [origin[0] + dir[0] * hit.t, origin[1] + dir[1] * hit.t, origin[2] + dir[2] * hit.t];
if (sides[side]) {
sideGrab = { hand, side, local: toLocal(m, point), at: M.transformPoint(M.invertRigid(orbiterModel(side)), point) };
buzz(src, 0.5, 25);
}
} else if (grip && !h.grip && isKbHandle(hover) && !kbGrab) {
const id = hover.slice(3);
if (id === 'grab') grab = { hand, local: toLocal(m, [model[12], model[13], model[14]]) }; // moves the shared place
@@ -837,14 +932,18 @@
// what was last clicked: typing stops (the panel gets Escape, which puts away a
// keyboard that came up for it), an open menu on the controls panel closes, and a
// dialog is cancelled (anywhere off it counts, as with M3's scrim).
// With nothing to let go of (and no keyboard up), the press hides everything at once:
// the panels, the side toolbars and the search bar, as auto-hide would (paused too).
// With everything hidden (auto-hide, or the left D-pad), a trigger pull brings the
// panels back, and the lasers with them (they only show while the panels do).
if (trigger && !h.trigger) fling = null; // any press stops a scroll that keeps going
if (trigger && !h.trigger && !visible && !tHit) { visible = true; lastActivity = now; buzz(src, 0.3, 15); }
if (trigger && !h.trigger && !visible && !tHit) { visible = true; lastActivity = now; dismissedAt = -1; buzz(src, 0.3, 15); }
else if (trigger && !h.trigger && !onPanel && !tHit) {
const holding = !!(typing || menu || dialog || kbShown);
if (typing) { lastActivity = now; typing.ctrl.event({ type: 'key', value: 'Escape' }); }
menu = null;
closeDialog(false);
if (!holding) { visible = false; grab = null; sideGrab = null; dismissedAt = now; }
}
if (trigger && !h.trigger && hover) {
pressed = { hand, id: hover };
@@ -954,12 +1053,16 @@
// being up, counts as using it), each part after its own wait (Settings → Auto-hide,
// fade-options.js): everything (the controls panel, the open panels, the lasers), the
// side toolbars with their open panels, the search bar. Pointing at anything (where a
// faded one is, too) brings them back.
// faded one is, too) brings them back. With Settings → Auto-hide → "Auto-hide while paused"
// on, a paused video counts too (not with no video at all). After a trigger press on
// nothing hid everything, the toolbars and the search bar stay hidden until the next use.
const F = ns.vrFadeOptions, unused = now - lastActivity;
const idle = !st.paused && !scrub && !setDrag && !pressed && !grab && !menu && !dialog && !canSkip && !typingNow() && !kbShown;
if (dismissedAt >= 0 && lastActivity > dismissedAt) dismissedAt = -1;
const playing = !st.paused || (F.hideWhilePaused() && st.duration > 0);
const idle = playing && !scrub && !setDrag && !pressed && !grab && !sideGrab && !menu && !dialog && !canSkip && !typingNow() && !kbShown;
if (visible && idle && unused > F.seconds('panels') * 1000) visible = false;
barsAwake = !(idle && unused > F.seconds('bars') * 1000);
searchAwake = !(idle && unused > F.seconds('search') * 1000);
barsAwake = dismissedAt < 0 && !(idle && unused > F.seconds('bars') * 1000);
searchAwake = dismissedAt < 0 && !(idle && unused > F.seconds('search') * 1000);
// The keyboard: redrawn only when its keys (Shift, page, layout), what's pointed at or
// pressed, or what you're typing into change.
@@ -974,16 +1077,17 @@
}
// Redraw the canvas only when something visible changed.
let hover = null;
for (const h of hands.values()) if (h.hover) hover = h.hover;
let topModel = null;
if (visible) {
let hover = null;
for (const h of hands.values()) if (h.hover) hover = h.hover;
const quality = st.quality || { current: null, levels: [] };
const format = st.format || { current: null, options: [] };
const currentFormat = format.options.find(o => o.id === format.current);
let menuState = null;
if (menu === 'quality') {
menuState = {
items: quality.levels.map(q => ({ id: `q:${q.id}`, text: q.label, on: q.id === quality.current })),
items: quality.levels.map(q => ({ id: `q:${q.id}`, text: q.label, on: q.id === quality.current, icons: q.icons })),
note: quality.levels.length ? null : tr('panel.noQualities', { site: site.name || tr('panel.theSite') }),
};
} else if (menu === 'format') {
@@ -1061,7 +1165,6 @@
// The top panel's controller is asked every frame (its search bar is always shown):
// { model, key } as any panel's, plus bar (the bar's field) and open (show the panel).
let topModel = null;
if (topPanel) {
topModel = topPanel.ctrl.model(frameInfo);
topPanel.topOpen = !!topModel.open; topPanel.bar = topModel.bar || null;
@@ -1072,31 +1175,33 @@
for (const id of panels.keys()) openItems[`toggle:${id}`] = isOpen(id);
for (const [name, o] of Object.entries(orbiters)) {
const barUi = name === 'bar' ? { hover: localOf(hover, topPanel), pressed: localOf(pressed && pressed.id, topPanel) } : null;
const oKey = [name === 'bar' ? JSON.stringify([topPanel.bar, barUi, topPanel.topOpen]) : JSON.stringify(openItems), hover, pressed && pressed.id,
const held = sideGrab && sideGrab.side === name ? `sgrab:${name}` : null; // a side's handle while held
const oKey = [name === 'bar' ? JSON.stringify([topPanel.bar, barUi, topPanel.topOpen]) : JSON.stringify(openItems), hover, pressed && pressed.id, held,
ns.i18n.version(), ns.vrTokens.glass.version, o.ui.moving() ? now : ''].join('|');
if (oKey === o.key) continue;
if (name === 'pill') o.ui.draw({ open: isOpen(popupPanel.reg.id), hover, pressed: pressed && pressed.id });
else if (name === 'bar') o.ui.draw(topPanel.bar || {}, barUi, topPanel.topOpen, now);
else o.ui.draw({ open: openItems, hover, pressed: pressed && pressed.id, now });
else o.ui.draw({ open: openItems, hover, pressed: pressed && pressed.id, held, now });
o.pass.upload();
o.key = oKey;
}
}
// The open panels: each controller says what to show (and a key that changes whenever
// that would look different); redrawn only when the key, the hover / pressed control
// or the loaded images change.
for (const p of panels.values()) {
if (!shown(p)) continue;
const { model: pm, key: ck } = p === topPanel ? topModel : p.ctrl.model(frameInfo);
const pui = { hover: localOf(hover, p), pressed: localOf(pressed && pressed.id, p) };
const pKey = [ck, pui.hover, pui.pressed, p.style.imagesReady(), ns.i18n.version(), ns.vrTokens.glass.version,
p.style.animating && p.style.animating() ? now : ''].join('\u0001'); // every frame while a hover / press eases
if (pKey !== p.key) {
p.style.draw(pm, pui);
if (p.ctrl.afterDraw) p.ctrl.afterDraw();
try { p.pass.upload(); } catch (err) { console.warn(`[streamxr] ${p.reg.id} panel blocked (images):`, err); }
p.key = pKey;
}
// The open panels (while the panels are up; one that stays, also while they're hidden):
// each controller says what to show (and a key that changes whenever that would look
// different); redrawn only when the key, the hover / pressed control or the loaded
// images change.
for (const p of panels.values()) {
if (!shown(p)) continue;
const { model: pm, key: ck } = p === topPanel ? topModel : p.ctrl.model(frameInfo);
const pui = { hover: localOf(hover, p), pressed: localOf(pressed && pressed.id, p) };
const pKey = [ck, pui.hover, pui.pressed, p.style.imagesReady(), ns.i18n.version(), ns.vrTokens.glass.version,
p.style.animating && p.style.animating() ? now : ''].join('\u0001'); // every frame while a hover / press eases
if (pKey !== p.key) {
p.style.draw(pm, pui);
if (p.ctrl.afterDraw) p.ctrl.afterDraw();
try { p.pass.upload(); } catch (err) { console.warn(`[streamxr] ${p.reg.id} panel blocked (images):`, err); }
p.key = pKey;
}
}
}
@@ -1147,7 +1252,8 @@
function drawPanel(viewerPose, layer, backdrop = null) {
if (!fovMeasured) { ns.vrSafeZone.measure(viewerPose); fovMeasured = true; }
const V = Math.min(1, Math.max(0, anim.vis.get()));
if (V < 0.01) return;
const staying = [...panels.values()].some(p => p.reg.stays && isOpen(p.reg.id)); // (drawn while the rest is hidden)
if (V < 0.01 && !staying) return;
const K = Math.min(1, Math.max(0, anim.kb.get())), vRaw = anim.vis.get(), kRaw = anim.kb.get();
gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL);
gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
@@ -1158,12 +1264,13 @@
const cpA = V * (1 - K), kbA = V * K, C = [0, 0];
if (cpA > 0.01) items.push([panelPass, scaled(model, C, shrink(vRaw) * shrink(1 - kRaw)), size, backdrop, cpA]);
if (kbA > 0.01) items.push([kbPass, scaled(kbModel(), C, shrink(vRaw) * shrink(kRaw)), kbSize(), backdrop, kbA]);
// (a side panel also fades with its side's toolbar: anim.bars, the side toolbars' own wait)
// (a side panel also fades with its side's toolbar: anim.bars, the side toolbars' own
// wait; one that stays fades with neither, only opening and closing)
const B = Math.min(1, Math.max(0, anim.bars.get()));
for (const p of panels.values()) {
const a = anim.open.get(p.reg.id), o = a ? a.get() : 0;
const oa = Math.min(1, Math.max(0, o)) * V * (p.zone === 'left' || p.zone === 'right' ? B : 1);
if (oa > 0.01 && (p.zone === 'controls' || p.pose)) items.push([p.pass, scaled(panelModel(p), pivotOf(p), shrink(o) * shrink(vRaw)), p.size, backdrop, oa]);
const a = anim.open.get(p.reg.id), o = a ? a.get() : 0, stays = !!p.reg.stays;
const oa = Math.min(1, Math.max(0, o)) * (stays ? 1 : V * (p.zone === 'left' || p.zone === 'right' ? B : 1));
if (oa > 0.01 && (p.zone === 'controls' || p.pose)) items.push([p.pass, scaled(panelModel(p), pivotOf(p), shrink(o) * (stays ? 1 : shrink(vRaw))), p.size, backdrop, oa]);
}
for (const [name, o] of Object.entries(orbiters)) {
// the pill goes with the controls panel; the side toolbars and the search bar also
+4
View File
@@ -88,6 +88,8 @@
'fade.lasersHint': 'When the controller is held still',
'fade.seconds': '{n} s',
'fade.never': 'Never',
'fade.paused': 'Auto-hide while paused',
'fade.pausedHint': 'The waits above run with the video paused too',
'safezone.note': 'Where the panels go: a share of what your headset shows ({h}° × {v}°). The outline shows it.',
'safezone.width': 'Width',
'safezone.widthHint': 'Of your view, left to right',
@@ -101,6 +103,8 @@
'safezone.taller': 'Taller',
'safezone.lower': 'Lower',
'safezone.higher': 'Higher',
'safezone.recenter': 'Recenter resets panels',
'safezone.recenterHint': 'Recentering also puts the panels you moved back in their places',
'keyboard.note': 'The VR keyboard (A button). Default: your computer’s keyboard and language.',
'keyboard.style': 'Style',
'keyboard.language': 'Language',
+4
View File
@@ -80,6 +80,8 @@
'fade.lasersHint': 'Al dejar el mando quieto',
'fade.seconds': '{n} s',
'fade.never': 'Nunca',
'fade.paused': 'Ocultar automáticamente en pausa',
'fade.pausedHint': 'Las esperas de arriba cuentan también con el vídeo en pausa',
'safezone.note': 'Dónde van los paneles: una parte de lo que muestran tus gafas ({h}° × {v}°). El contorno la muestra.',
'safezone.width': 'Ancho',
'safezone.widthHint': 'De tu vista, de izquierda a derecha',
@@ -93,6 +95,8 @@
'safezone.taller': 'Más alta',
'safezone.lower': 'Más abajo',
'safezone.higher': 'Más arriba',
'safezone.recenter': 'Recentrar restablece los paneles',
'safezone.recenterHint': 'Al recentrar, los paneles que moviste también vuelven a su sitio',
'keyboard.note': 'El teclado de VR (botón A). Predeterminado: el teclado y el idioma de tu ordenador.',
'keyboard.style': 'Distribución',
'keyboard.language': 'Idioma',
+4
View File
@@ -89,6 +89,8 @@
'fade.lasersHint': 'When the controller is held still',
'fade.seconds': '{n} s',
'fade.never': 'Never',
'fade.paused': 'Auto-hide while paused',
'fade.pausedHint': 'The waits above run with the video paused too',
'safezone.note': 'Where the panels go: a share of what your headset shows ({h}° × {v}°). The outline shows it.',
'safezone.width': 'Width',
'safezone.widthHint': 'Of your view, left to right',
@@ -102,6 +104,8 @@
'safezone.taller': 'Taller',
'safezone.lower': 'Lower',
'safezone.higher': 'Higher',
'safezone.recenter': 'Recenter resets panels',
'safezone.recenterHint': 'Recentering also puts the panels you moved back in their places',
'keyboard.note': 'The VR keyboard (A button). Default: your computer’s keyboard and language.',
'keyboard.style': 'Style',
'keyboard.language': 'Language',
+4
View File
@@ -81,6 +81,8 @@
'fade.lasersHint': 'Al dejar el mando quieto',
'fade.seconds': '{n} s',
'fade.never': 'Nunca',
'fade.paused': 'Ocultar automáticamente en pausa',
'fade.pausedHint': 'Las esperas de arriba cuentan también con el vídeo en pausa',
'safezone.note': 'Dónde van los paneles: una parte de lo que muestran tus gafas ({h}° × {v}°). El contorno la muestra.',
'safezone.width': 'Ancho',
'safezone.widthHint': 'De tu vista, de izquierda a derecha',
@@ -94,6 +96,8 @@
'safezone.taller': 'Más alta',
'safezone.lower': 'Más abajo',
'safezone.higher': 'Más arriba',
'safezone.recenter': 'Recentrar restablece los paneles',
'safezone.recenterHint': 'Al recentrar, los paneles que moviste también vuelven a su sitio',
'keyboard.note': 'El teclado de VR (botón A). Predeterminado: el teclado y el idioma de tu ordenador.',
'keyboard.style': 'Distribución',
'keyboard.language': 'Idioma',
@@ -0,0 +1,370 @@
// YouTube's "Live chat" panel (Info style, 'chat' content): a live stream's chat, in the
// right zone, shown only while one is playing (its registration's `available`) and staying up
// while the rest of the panels fade (`stays`). Its tabs are YouTube's chat views (Top chat,
// Live chat; the one chosen is remembered). While the panel is open the chat is polled every
// POLL_MS (youtube-chat.js; YouTube's own frame is pushed new messages, so its suggested 10 s
// wait would lag); closed, it rests, and reopening after a while starts it afresh. The list
// follows the newest message; scrolling back pauses it (new messages are counted, not
// shown) until you scroll to the end or press "jump to newest". The stream's reaction buttons
// (❤ 😄 🎉, from the chat page) sit along the panel's bottom: a click sends one (at most one
// every REACT_GAP_MS) and lights the button a moment. Above them, the message field: a click
// brings up the VR keyboard (with the channel's own emojis first on its emoji pages: typed as
// their shortcuts, sent as emojis), Enter or Send posts the message, Escape (or a click on
// nothing) stops typing; where you can't chat, YouTube's reason shows instead. Support opens a
// dialog: paying (Super Chat, gifts, memberships) happens in YouTube's own window on the
// desktop. Top fans: your XP in a 👑 pill at the right of the tabs (kept up to date by the
// polls); it opens the ranked list in the panel (refreshed every FANS_REFRESH_MS while open; a
// back button returns to the chat), and the top three have their "#1" pills in the chat.
// A finished stream's chat replay plays along with the video instead: its recorded messages
// are fetched ahead (REPLAY_AHEAD_MS) and each shows when the video reaches its time, so a
// pause stops the chat; a jump back, or ahead past SEEK_AHEAD_MS, starts it again at the new
// place (with the last REPLAY_BACKFILL_MS of chat at once). No typing, reactions or Top fans
// there, as on YouTube.
// A panel controller: see panels.js.
// frame.st used: liveChat (youtubeChat.info(): { replay, views } | null), time (a replay's place).
(function (ns) {
'use strict';
const POLL_MS = 2000; // how often to ask for new messages
const RETRY_MS = 5000; // after a failed request
const MAX_TRIES = 3; // failed requests in a row before giving up (a tab click retries)
const RESTART_MS = 30000; // closed longer than this: start afresh when reopened
const START_TRIES = 5, START_RETRY_MS = 3000; // a chat that won't load yet (e.g. YouTube's chat frame still
// loading for a new video): tried again this often, the spinner showing
const MAX_ITEMS = 150; // messages kept while following (more while paused, up to MAX_PAUSED)
const MAX_PAUSED = 600;
const REACT_GAP_MS = 500; // the shortest time between two reactions sent
const REACT_LIT_MS = 700; // how long a sent reaction's button stays lit
const FANS_REFRESH_MS = 30000; // how often the open Top fans list is loaded again
const REPLAY_AHEAD_MS = 30000; // a replay: fetch more while less than this is waiting ahead of the video
const REPLAY_BACKFILL_MS = 60000; // a replay: on starting or a jump, the chat from this long before shows at once
const SEEK_BACK_MS = 1500, SEEK_AHEAD_MS = 5000; // the video jumping back / ahead more than this is a seek
const VIEW_KEY = 'chatView'; // storage.js name: the chosen view's position (0: Top chat, 1: Live chat)
const tr = (key, vars) => ns.i18n.t(key, vars); // words in the interface language (strings-en.js)
// A chat message (youtube-chat.js) -> the shell's chat item (chat.js), in YouTube's colours
// (the theme's custom roles: provider.js): the owner's name on a yellow chip, moderators in
// blue with a wrench, members in green with their badge; Super Chats / Stickers as cards in
// their tier's colours, memberships as green cards.
function itemOf(msg) {
const badges = [];
if (msg.badge) badges.push({ image: msg.badge });
if (msg.role === 'moderator') badges.push({ icon: 'construction', color: 'chatModerator' });
if (msg.verified) badges.push({ icon: 'verified', color: 'onSurfaceVariant' });
const it = { id: msg.id, author: msg.author, avatar: msg.avatar, channelId: msg.channelId, badges, rank: msg.rank || null,
runs: msg.runs.map(r => (r.emoji ? { image: r.emoji.url, label: r.emoji.label } : { text: r.text })) };
if (msg.role === 'owner') { it.nameFill = 'chatOwner'; it.nameColor = 'onChatOwner'; }
else if (msg.role === 'moderator') it.nameColor = 'chatModerator';
else if (msg.role === 'member') it.nameColor = 'chatMember';
if (msg.kind === 'notice') return { ...it, kind: 'notice' };
if (msg.kind === 'paid' || msg.kind === 'sticker') {
return { ...it, kind: 'card', card: { color: msg.colors.header || 'chatMember', onColor: msg.colors.text || 'onSurface', title: msg.amount, sticker: msg.sticker || '' } };
}
if (msg.kind === 'member') {
return { ...it, kind: 'card', card: { color: 'chatMember', onColor: 'onChatMember', title: msg.header || tr('yt.chat.newMember') } };
}
return { ...it, kind: 'text' };
}
function create(host) {
let view = null; // the chosen view's position in st.liveChat.views
{ const v = parseInt(ns.storage.get(VIEW_KEY), 10); if (v >= 0) view = v; }
// The chat being followed: { video, view (its position), replay, items, ids, banner, next,
// status ('loading' | 'live' | 'ended' | 'error'), busy (a request out), due (ms: next
// poll), tries, version (changes with what's shown), and youtube-chat.js open()'s reactions,
// input, emojis, support; a replay also: first (its start: the token a seek asks with),
// queue (fetched changes waiting for their time, `at` ms), queueEnd (the last `at` fetched),
// needSeek (fetch from the video's place with `first`), lastT (the video's last place, ms) }
let chat = null, lastOpen = 0;
let lastReact = 0, lit = {}; // when the last reaction was sent; when each button was (ms)
let draft = '', typing = false, sending = false, sendError = ''; // the message being typed
// The Top fans list while it's open: { rows, you, loading, error, busy, at (ms: last
// loaded), version }, and how far it's scrolled.
let fans = null, fansScroll = 0;
let follow = true, frozen = null, unseen = 0, scroll = 0;
const viewsOf = st => (st.liveChat && st.liveChat.views) || [];
// The view to show: the one chosen, else the one YouTube selects.
function chosen(views) {
if (view != null && views[view]) return view;
const i = views.findIndex(v => v.selected);
return i >= 0 ? i : 0;
}
// Start following view `index` (titled `title`; a replay's or not): find the token that
// loads it (youtube-chat.js views(): the same title, else the same position), then load it.
// (attempt: how many starts failed before this one: see START_TRIES)
function start(index, title, replay, attempt = 0) {
const c = chat = { video: ns.youtubeData.currentVideoId(), view: index, title, attempt, retryAt: 0, replay: !!replay, items: [], ids: new Set(), banner: null, next: null,
status: 'loading', busy: true, due: 0, tries: 0, version: 0, reactions: [], input: null, emojis: [], support: [], leaderboard: null, xp: null,
first: null, queue: [], queueEnd: -1, needSeek: false, lastT: null };
follow = true; frozen = null; unseen = 0; scroll = 0;
stopTyping(); draft = ''; sendError = ''; // (another chat: a new message)
fans = null; fansScroll = 0;
ns.youtubeChat.views({ replay: c.replay }).then(list => {
const v = (title && list.find(x => x.title === title)) || list[index] || list[0];
return ns.youtubeChat.open(v.token, c.replay);
}).then(r => {
if (chat !== c) return;
c.next = r.next; c.status = r.next ? 'live' : 'ended';
if (c.replay) { c.first = r.next; c.needSeek = true; return; } // (a replay starts at the video's place: tickReplay)
apply(c, r.items);
c.reactions = r.reactions || []; c.input = r.input || null; c.emojis = r.emojis || []; c.support = r.support || [];
c.leaderboard = r.leaderboard || null; c.xp = c.leaderboard ? c.leaderboard.xp : null;
}, err => {
if (chat !== c) return;
if (attempt + 1 < START_TRIES) { c.status = 'retry'; c.retryAt = performance.now() + START_RETRY_MS; return; } // (tick tries again)
console.warn('[streamxr] live chat unavailable:', err && err.message || err);
c.status = 'error';
}).then(() => { c.busy = false; c.due = performance.now() + (c.replay ? 0 : POLL_MS); }); // (a replay fetches at once)
}
// Apply a batch of changes (youtube-chat.js) to the chat.
function apply(c, changes) {
let added = 0;
for (const ch of changes) {
if (ch.op === 'add' && !c.ids.has(ch.msg.id)) { c.ids.add(ch.msg.id); c.items.push(itemOf(ch.msg)); added++; }
else if (ch.op === 'remove') c.items = c.items.filter(it => it.id !== ch.id);
else if (ch.op === 'removeAuthor') c.items = c.items.filter(it => it.channelId !== ch.channelId);
else if (ch.op === 'banner') c.banner = { id: ch.banner.id, header: ch.banner.header, item: itemOf(ch.banner.msg) };
else if (ch.op === 'unbanner' && c.banner && (!ch.id || ch.id === c.banner.id)) c.banner = null;
}
const max = follow ? MAX_ITEMS : MAX_PAUSED;
if (c.items.length > max) c.items = c.items.slice(-max);
if (c.ids.size > MAX_PAUSED * 4) c.ids = new Set(c.items.map(it => it.id)); // (forget long-gone ids)
if (!follow) unseen += added;
if (changes.length) c.version++;
}
function poll(c, now) {
c.busy = true;
ns.youtubeChat.poll(c.next, c.leaderboard && c.leaderboard.pointsKey).then(r => {
if (chat !== c) return;
apply(c, r.items); c.next = r.next; c.tries = 0;
if (r.xp !== undefined && r.xp !== c.xp) { c.xp = r.xp; c.version++; }
if (!r.next) c.status = 'ended';
c.due = performance.now() + POLL_MS;
}, err => {
if (chat !== c) return;
if (++c.tries >= MAX_TRIES) { console.warn('[streamxr] live chat stopped:', err && err.message || err); c.status = 'error'; }
c.due = performance.now() + RETRY_MS;
}).then(() => { c.busy = false; });
}
// A replay, every frame: a seek starts it again; what's due by the video's place shows; more
// is fetched while little waits ahead.
function tickReplay(c, frame) {
const t = Math.round((frame.st.time || 0) * 1000);
if (c.lastT != null && (t < c.lastT - SEEK_BACK_MS || t > c.lastT + SEEK_AHEAD_MS)) {
c.items = []; c.ids = new Set(); c.banner = null; c.queue = []; c.queueEnd = -1; c.needSeek = true; c.version++;
resume();
}
c.lastT = t;
let due = 0;
while (due < c.queue.length && c.queue[due].at <= t) due++;
if (due) apply(c, c.queue.splice(0, due));
if (!c.busy && performance.now() >= c.due && (c.needSeek || (c.next && c.queueEnd - t < REPLAY_AHEAD_MS))) fetchReplay(c, t);
}
// Fetch a replay's next chunk (or, for a seek, the chunk from a little before the video's place).
function fetchReplay(c, t) {
const seek = c.needSeek, from = seek ? Math.max(0, t - REPLAY_BACKFILL_MS) : t;
c.busy = true;
ns.youtubeChat.pollReplay(seek ? c.first : c.next, from).then(r => {
if (chat !== c) return;
if (seek) { c.queue = []; c.needSeek = false; }
const items = r.items.map(ch => (ch.at != null ? ch : { ...ch, at: from }));
c.queue.push(...items);
if (items.length) c.queueEnd = Math.max(c.queueEnd, items[items.length - 1].at);
c.next = r.next;
if (!r.next) c.queueEnd = Infinity; // the end of the recorded chat: nothing more to fetch
c.tries = 0;
c.due = performance.now() + (items.length ? 0 : POLL_MS);
}, err => {
if (chat !== c) return;
if (++c.tries >= MAX_TRIES) { console.warn('[streamxr] chat replay stopped:', err && err.message || err); c.status = 'error'; }
c.due = performance.now() + RETRY_MS;
}).then(() => { c.busy = false; });
}
// Every frame: while the panel is open, start the chat (or start it again: another video,
// another view, live or replay, or reopened after a while) and poll it when due (a replay:
// tickReplay).
function tick(frame) {
if (!host.isOpen()) return;
const now = frame.now, views = viewsOf(frame.st), replay = !!(frame.st.liveChat && frame.st.liveChat.replay);
if (!views.length) { chat = null; return; }
const i = chosen(views);
const stale = now - lastOpen > RESTART_MS;
lastOpen = now;
if (!chat || chat.view !== i || chat.replay !== replay || chat.video !== ns.youtubeData.currentVideoId() || (stale && !chat.busy)) {
start(i, views[i].title, replay); return;
}
if (chat.status === 'retry') { if (performance.now() >= chat.retryAt) start(chat.view, chat.title, chat.replay, chat.attempt + 1); return; }
if (chat.replay) { if (chat.status === 'live') tickReplay(chat, frame); }
else if (chat.status === 'live' && !chat.busy && now >= chat.due && chat.next) poll(chat, now);
if (fans && fans.at && !fans.busy && performance.now() - fans.at > FANS_REFRESH_MS) loadFans();
}
// ------------------------------------------------------------ Top fans
function openFans() {
if (!chat || !chat.leaderboard) return;
fans = { rows: [], you: null, loading: true, error: '', busy: false, at: 0, version: 0 }; fansScroll = 0;
loadFans();
}
function loadFans() {
const f = fans;
if (!f || !chat || !chat.leaderboard) return;
f.busy = true;
ns.youtubeChat.topFans(chat.leaderboard.params).then(r => {
if (fans !== f) return;
f.rows = r.fans; f.you = r.you ? { ...r.you, name: r.you.name || tr('yt.chat.you') } : null; f.error = '';
}, err => {
if (fans !== f) return;
console.warn('[streamxr] top fans unavailable:', err && err.message || err);
if (!f.rows.length) f.error = tr('yt.chat.fansError');
}).then(() => { f.busy = false; f.loading = false; f.at = performance.now(); f.version++; });
}
function model(frame) {
const views = viewsOf(frame.st), i = chosen(views);
const tabs = views.length ? views.map((v, k) => ({ id: String(k), label: v.title || tr('yt.panel.chat') })) : [{ id: '0', label: tr('yt.panel.chat') }];
let content;
if (!views.length) content = { kind: 'message', text: tr('yt.chat.none') };
else if (!chat || ((chat.status === 'loading' || chat.status === 'retry') && !chat.items.length)) content = { kind: 'message', text: tr('yt.loading'), spinner: true };
else if (chat.status === 'error' && !chat.items.length) content = { kind: 'message', text: tr('yt.chat.error') };
else {
// (an emoji written without its emoji selector, as YouTube writes ❤, gets one: drawn in colour, not as a plain glyph)
const reactions = chat.status === 'live' ? chat.reactions.map((r, k) => ({ id: String(k), emoji: /️$/.test(r.emoji) ? r.emoji : `${r.emoji}️`,
on: frame.now - (lit[k] || -Infinity) < REACT_LIT_MS })) : [];
content = { kind: 'chat', banner: chat.banner, items: follow ? chat.items : (frozen || chat.items), follow, reactions,
input: inputModel(), support: chat.status === 'live' && chat.support.length > 0,
xp: chat.leaderboard ? { label: chat.xp || tr('yt.chat.topFans') } : null,
fans: fans ? { title: tr('yt.chat.topFans'), loading: fans.loading, error: fans.error, empty: tr('yt.chat.fansEmpty'), rows: fans.rows, you: fans.you } : null,
labels: { newest: unseen ? tr('yt.chat.newestCount', { n: unseen }) : tr('yt.chat.newest'), empty: chat.status === 'ended' ? tr('yt.chat.ended') : tr('yt.chat.empty'),
send: tr('yt.chat.send'), support: tr('yt.chat.support') } };
}
const m = { tabs, tab: String(i), content, scroll: fans ? fansScroll : scroll, now: frame.now / 1000 };
const loading = !chat || chat.status === 'loading' || chat.status === 'retry';
const lits = content.reactions ? content.reactions.map(r => (r.on ? 1 : 0)).join('') : '';
const key = [tabs.map(t => t.label).join(','), i, chat ? `${chat.video}:${chat.status}:${follow ? chat.version : 'paused'}` : '',
follow, unseen, Math.round(scroll), chat && chat.banner ? chat.banner.id : '', lits, draft, typing, sending, sendError,
chat ? chat.xp : '', fans ? `${fans.version}:${Math.round(fansScroll)}` : '',
loading || (fans && fans.loading) ? Math.floor(frame.now / 33) : '', ns.i18n.version()].join('#');
return { model: m, key };
}
// After a draw: keep the scroll where the style put it; at the end again, follow again.
function afterDraw() {
if (fans) { fansScroll = host.style.shownScroll(); return; }
scroll = host.style.shownScroll();
if (!follow && scroll >= host.style.maxScroll() - 2 && frozen) resume();
}
function resume() { follow = true; frozen = null; unseen = 0; if (chat && chat.items.length > MAX_ITEMS) chat.items = chat.items.slice(-MAX_ITEMS); }
// ------------------------------------------------------------ typing a message
const canType = () => !!(chat && chat.status === 'live' && chat.input && chat.input.params);
// The draft in pieces: a channel emoji's shortcut (':_wave:', as its key types it), or the
// text between them.
function pieces(text) {
const map = new Map((chat ? chat.emojis : []).map(e => [e.shortcut, e])), out = [];
const re = /:[^:\s]+:/g;
let at = 0, mt;
while ((mt = re.exec(text))) {
const e = map.get(mt[0]);
if (!e) continue;
if (mt.index > at) out.push({ text: text.slice(at, mt.index) });
out.push({ emoji: e }); at = mt.index + mt[0].length;
}
if (at < text.length) out.push({ text: text.slice(at) });
return out;
}
// How long YouTube counts it: characters, a channel emoji counting as input.emojiCost.
const lengthOf = text => pieces(text).reduce((n, p) => n + (p.emoji ? chat.input.emojiCost : Array.from(p.text).length), 0);
// The message row for the model: why you can't chat, or the field.
function inputModel() {
const input = chat && chat.status === 'live' ? chat.input : null;
if (!input) return null;
if (!input.params) return input.restricted ? { note: input.restricted } : null;
return { runs: pieces(draft).map(p => (p.emoji ? { image: p.emoji.image, label: p.emoji.shortcut } : { text: p.text })),
placeholder: input.placeholder || tr('yt.chat.placeholder'), focused: typing, canSend: !!draft.trim() && !sending, error: sendError };
}
// The keys come to the field (the keyboard's emoji pages starting with the channel's own).
function startTyping() {
if (!canType()) return;
typing = true;
host.typeInto(true, { emojis: chat.emojis.map(e => ({ text: e.shortcut, image: e.image, label: e.label })) });
}
function stopTyping() { if (typing) { typing = false; host.typeInto(false); } }
// A key from the keyboard: a character (or an emoji, or a channel emoji's shortcut) is
// added while it fits YouTube's limit; Backspace takes off the last character or emoji;
// Enter sends; Escape stops typing.
function key(v) {
if (v === 'Escape') { stopTyping(); return; }
if (v === 'Enter') { sendDraft(); return; }
sendError = '';
if (v === 'Backspace') {
const last = pieces(draft).pop();
if (last) draft = draft.slice(0, draft.length - (last.emoji ? last.emoji.shortcut.length : (Array.from(last.text).pop() || '').length));
return;
}
if (canType() && lengthOf(draft + v) <= chat.input.max) draft += v;
}
// Post the draft (text and channel emojis); cleared once YouTube took it, then the list
// goes back to the newest (where it will appear). Typing goes on.
function sendDraft() {
if (!canType() || sending || !draft.trim()) return;
const text = draft, segments = pieces(text).map(p => (p.emoji ? { emojiId: p.emoji.id } : { text: p.text }));
sending = true; sendError = '';
ns.youtubeChat.send(chat.input, segments).then(ok => {
if (ok) { if (draft === text) draft = ''; resume(); } else sendError = tr('yt.chat.sendFailed');
}, err => { console.warn('[streamxr] chat message not sent:', err && err.message || err); sendError = tr('yt.chat.sendFailed'); })
.then(() => { sending = false; });
}
// Support: YouTube takes payments in its own window, so the dialog says where to go and
// what this stream offers there.
function support() {
const offers = chat ? chat.support.join(', ') : '';
host.confirm({ title: tr('yt.chat.supportTitle'), text: offers ? tr('yt.chat.supportTextWith', { offers }) : tr('yt.chat.supportText'),
confirm: tr('yt.chat.supportOk'), cancel: null });
}
// Send reaction k (lit at once; a refusal is only logged: YouTube shows nothing either).
function react(k) {
const r = chat && chat.reactions[k], now = performance.now();
if (!r || now - lastReact < REACT_GAP_MS) return;
lastReact = now; lit[k] = now;
ns.youtubeChat.react(r).then(ok => { if (!ok) console.warn('[streamxr] reaction not taken:', r.emoji); },
err => console.warn('[streamxr] reaction failed:', err && err.message || err));
}
function event(ev) {
if (ev.type === 'tab') {
const k = +ev.value;
if (k !== view) { view = k; ns.storage.set(VIEW_KEY, String(k)); chat = null; }
} else if (ev.type === 'newest') resume();
else if (ev.type === 'react') react(+ev.value);
else if (ev.type === 'field') startTyping();
else if (ev.type === 'send') sendDraft();
else if (ev.type === 'key') { if (typing) key(ev.value); }
else if (ev.type === 'support') support();
else if (ev.type === 'fans') { if (fans) fans = null; else openFans(); }
else if (ev.type === 'fansBack') fans = null;
else if (ev.type === 'scroll' && fans) fansScroll = Math.max(0, Math.min(host.style.maxScroll(), ev.value));
else if (ev.type === 'scroll') {
const max = host.style.maxScroll();
scroll = Math.max(0, Math.min(max, ev.value));
if (follow && scroll < max - 4) { follow = false; frozen = chat ? chat.items.slice() : null; unseen = 0; } // scrolled back: pause
}
}
return { model, event, tick, afterDraw };
}
ns.ytVr = ns.ytVr || {};
ns.ytVr.liveChatPanel = { create };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+10 -3
View File
@@ -11,12 +11,17 @@
// symbols.js), style ('queue' | 'catalog' | 'profile' | 'info' | 'options'), size ('wide'
// | 'tall' | 'standard', see vr-panel/config.js), overrides (the style's slots, optional),
// default (optional: the panel open the first time on its side, before one is
// remembered), create(host) -> controller }.
// remembered), available (optional, side panels: st => bool, asked every frame: while
// false, its toolbar item is hidden and it can't be open), autoOpen (optional: open it
// whenever it becomes available, without remembering it; the panel it replaced comes back
// when it goes), stays (optional: it doesn't fade with the rest while you watch),
// create(host) -> controller }.
// host (given by vr-panel.js): { actions (the session's: provider + shell), style (this panel's style instance:
// maxScroll(), shownScroll() ...), perPage (cards / rows per page), open(), close(),
// isOpen() (this panel), closeMenu() (the controls panel's open menu), confirm(d) (ask
// in the shell's dialog: d = { title, text, confirm, cancel, danger } -> Promise of bool),
// typeInto(on) (ask
// typeInto(on, opts) (opts.emojis: [{ text, image, label }], the panel's own emojis for the
// keyboard's emoji pages; ask
// for the keys, or give them back (asking brings up the in-VR keyboard): while on and
// shown, each key arrives as an event
// { type: 'key', value: a character | 'Backspace' | 'Enter' | 'Escape' }) }.
@@ -47,7 +52,9 @@
overrides: footer },
{ id: 'channel', zone: 'left', label: () => ns.i18n.t('yt.panel.channel'), icon: 'account_box', style: 'profile', size: 'tall', create: yt.channelPanel.create,
overrides: footer }, // an opened post's comments
// right of the Safe Zone: what to watch
// right of the Safe Zone: a live stream's chat (only then; it stays while you watch), and what to watch
{ id: 'chat', zone: 'right', label: () => ns.i18n.t('yt.panel.chat'), icon: 'chat_bubble', style: 'info', size: 'tall', create: yt.liveChatPanel.create,
available: st => !!st.liveChat, autoOpen: true, stays: true },
{ id: 'recs', zone: 'right', label: () => ns.i18n.t('yt.panel.recs'), icon: 'playlist_play', style: 'queue', size: 'wide', create: yt.upNextPanel.create, default: true },
{ id: 'browse', zone: 'right', label: () => ns.i18n.t('yt.panel.browse'), icon: 'explore', style: 'catalog', size: 'wide', create: yt.browsePanel.create }, // Explore
{ id: 'you', zone: 'right', label: () => ns.i18n.t('yt.panel.you'), icon: 'person', style: 'catalog', size: 'wide', create: yt.youPanel.create },
+12 -5
View File
@@ -26,7 +26,7 @@
// setVolume, setMuted, storyboard, skipAd, previousVideo, nextVideo, extra; the rest are
// for this site's own panels),
// state(): polled every frame: { title, nowPlaying (see nowPlaying below), time, duration,
// live, paused, quality: { current, currentLabel, levels: [{ id, label }] }, speed,
// live, paused, quality: { current, currentLabel, icons, levels: [{ id, label, icons }] }, speed,
// speeds, volume, chapters, heatmap, ad, extras (its own controls panel buttons:
// vr-panel.js; used through actions.extra), ...whatever this site's panels read },
// ad() -> null | { ... } the ad playing now (the shell shows ads on the cinema screen)
@@ -224,6 +224,10 @@
// second). The seek bar then covers the rewindable part (time 0 = its start, duration =
// its length) and the time shows the LIVE label (`liveLabel`).
const liveNow = cached(() => { const l = youtube.live(); return l.isLive ? l : null; }, 250, null);
// The video's chat (youtube-chat.js info(): { replay, views }; re-read every second), or
// null: a live stream's live chat, or a finished stream's chat replay (state(): liveChat).
const liveChat = cached(() => ns.youtubeChat.info(), 1000, null);
const chatFor = l => { const c = liveChat(); return c && (c.replay ? !l : !!l) ? c : null; };
const LIVE_SLACK = 12; // seconds behind the live moment that still count as live
// How far behind live: the larger of (a) the gap to the rewindable end and (b) the
// broadcast delay (now - ingestion) beyond its smallest value seen for this video (the
@@ -354,7 +358,7 @@
title, time: l ? l.current - l.start : youtube.currentTime(), duration: l ? l.end - l.start : youtube.duration(),
live: l && liveLabel(l), paused: youtube.isPaused(),
buffered: l || ad() ? null : youtube.bufferedEnd(), // the seek bar's loaded part (not on live streams or ads)
quality: { current: q.current, currentLabel: qualityName(q.current, q.labels), icons: qualityIcons(q.current, q.labels), levels: (q.levels || []).map(id => ({ id, label: qualityName(id, q.labels) })) },
quality: { current: q.current, currentLabel: qualityName(q.current, q.labels), icons: qualityIcons(q.current, q.labels), levels: (q.levels || []).map(id => ({ id, label: qualityName(id, q.labels), icons: qualityIcons(id, q.labels) })) },
speed: youtube.speed(), speeds: youtube.speeds(), volume: youtube.volume(), chapters: chapters(), heatmap: l ? [] : heatmap(), ad: ad(),
nowPlaying: nowPlaying({ title, channel, details, playlist: pl, subscribeUi: subscribeUi() }),
// For YouTube's panels:
@@ -362,12 +366,13 @@
recommendations: page.recommendations(),
playback: { loop: youtube.loop(), autoplay: !!youtube.autoplay() }, audio: audioTracks(),
extras: extras.state({ ad: ad() }),
liveChat: chatFor(l), // a live stream's chat, or a finished one's chat replay (live-chat.js)
};
// No video playing (VR started from the Home page...): what the panels show instead of
// the last video's leftovers (YouTube may keep its old player around): a line saying how
// to pick one, an empty seek bar, nothing in Up Next or Description.
if (watching()) return st;
return { ...st, title: tr('yt.noVideo'), time: 0, duration: 0, paused: true, live: null, buffered: null, chapters: [], heatmap: [], ad: null,
return { ...st, title: tr('yt.noVideo'), time: 0, duration: 0, paused: true, live: null, buffered: null, chapters: [], heatmap: [], ad: null, liveChat: null,
nowPlaying: { info: [{ text: tr('yt.noVideoHint'), color: 'onSurfaceVariant' }], creator: null, actions: [], queue: null },
channelId: null, details: {}, playlist: null, description: null, recommendations: [], extras: extras.state({ ad: null, noVideo: true }) };
}
@@ -377,13 +382,15 @@
ns.ytVr = ns.ytVr || {};
// YouTube's brand colours (its brand page): YouTube Red, Almost Black (vr-panel/tokens.js),
// and its card flags' own colours (cards.js): VR180 purple, Mix orange, Playlist green.
// its card flags' own colours (cards.js): VR180 purple, Mix orange, Playlist green, and its
// live chat's (live-chat.js): the owner's yellow name chip, moderators' blue, members' green.
// seed: its Material 3 palette is made from YouTube Red when VR starts (tokens.js
// scheme()), in the Fidelity variant, with the pink and the deep red swapped (buttons
// pink, accents red); the accents themselves use the exact YouTube Red (`accent`, the
// brand role). Decided in docs/Materials-Redesign.md, "The colours".
const M3_THEME = { accent: '#FF0033', accentPressed: '#ff4d70', dark: '#212121', onAccent: '#FFFFFF',
custom: { vr180: '#b388ff', mix: '#ff8a00', playlist: '#2ecc71' },
custom: { vr180: '#b388ff', mix: '#ff8a00', playlist: '#2ecc71',
chatOwner: '#ffd600', onChatOwner: '#0f0f0f', chatModerator: '#5e84f1', chatMember: '#2ba640', onChatMember: '#ffffff' },
seed: '#FF0033', scheme: 'fidelity',
swap: [['primary', 'secondaryContainer'], ['onPrimary', 'onSecondaryContainer']] };
// The "YouTube" theme's roles, set over that palette (tokens.js theme.palette): Almost Black
@@ -23,6 +23,7 @@
'yt.panel.browse': 'Explore',
'yt.panel.you': 'You',
'yt.panel.recs': 'Up Next',
'yt.panel.chat': 'Live chat',
'yt.panel.settings': 'Settings',
// Shared by several panels.
@@ -121,6 +122,27 @@
'yt.comments.failed': 'Couldn’t load comments ({error}).',
'yt.comments.off': 'Comments are turned off for this video.',
// Live chat panel.
'yt.chat.none': 'This video has no live chat.',
'yt.chat.empty': 'No messages yet.',
'yt.chat.ended': 'The chat has ended.',
'yt.chat.error': 'Couldn’t load the chat.',
'yt.chat.newest': 'Jump to newest',
'yt.chat.newestCount': { one: '{n} new message', other: '{n} new messages' },
'yt.chat.newMember': 'New member',
'yt.chat.placeholder': 'Chat…',
'yt.chat.send': 'Send',
'yt.chat.sendFailed': 'Not sent',
'yt.chat.support': 'Support on desktop',
'yt.chat.supportTitle': 'Support on desktop',
'yt.chat.supportText': 'Paying happens in YouTube’s own window: take off your headset and use the $ button under the chat.',
'yt.chat.supportTextWith': '{offers}: in YouTube’s own window. Take off your headset and use the $ button under the chat.',
'yt.chat.supportOk': 'Got it',
'yt.chat.topFans': 'Top fans',
'yt.chat.you': 'You',
'yt.chat.fansEmpty': 'No top fans yet.',
'yt.chat.fansError': 'Couldn’t load the top fans.',
// Channel panel.
'yt.channel.videos': 'Videos',
'yt.channel.shorts': 'Shorts',
@@ -13,6 +13,7 @@
'yt.panel.browse': 'Explorar',
'yt.panel.you': 'Tú',
'yt.panel.recs': 'A continuación',
'yt.panel.chat': 'Chat en directo',
'yt.panel.settings': 'Ajustes',
// Shared by several panels.
@@ -111,6 +112,27 @@
'yt.comments.failed': 'No se pudieron cargar los comentarios ({error}).',
'yt.comments.off': 'Los comentarios están desactivados en este vídeo.',
// Live chat panel.
'yt.chat.none': 'Este vídeo no tiene chat en directo.',
'yt.chat.empty': 'Todavía no hay mensajes.',
'yt.chat.ended': 'El chat ha terminado.',
'yt.chat.error': 'No se pudo cargar el chat.',
'yt.chat.newest': 'Ir a lo más reciente',
'yt.chat.newestCount': { one: '{n} mensaje nuevo', other: '{n} mensajes nuevos' },
'yt.chat.newMember': 'Nuevo miembro',
'yt.chat.placeholder': 'Chatea…',
'yt.chat.send': 'Enviar',
'yt.chat.sendFailed': 'No enviado',
'yt.chat.support': 'Apoyar en el escritorio',
'yt.chat.supportTitle': 'Apoyar en el escritorio',
'yt.chat.supportText': 'Los pagos se hacen en la ventana de YouTube: quítate el visor y usa el botón $ bajo el chat.',
'yt.chat.supportTextWith': '{offers}: en la ventana de YouTube. Quítate el visor y usa el botón $ bajo el chat.',
'yt.chat.supportOk': 'Entendido',
'yt.chat.topFans': 'Fans destacados',
'yt.chat.you': 'Tú',
'yt.chat.fansEmpty': 'Todavía no hay fans destacados.',
'yt.chat.fansError': 'No se pudieron cargar los fans destacados.',
// Channel panel.
'yt.channel.videos': 'Vídeos',
'yt.channel.shorts': 'Shorts',
+340
View File
@@ -0,0 +1,340 @@
// A live stream's chat, read the way YouTube's own chat frame reads it (docs/youtube.md,
// "Live chat"): the watch page says there is a chat and names its views (Top chat, Live chat);
// the chat frame's data (or the pop-out chat page's, when the page shows no chat) holds each
// view's token; a view's chat page (/live_chat?continuation=<that token>) carries the recent
// messages and the token to poll with, and live_chat/get_live_chat answers each poll with
// what's new and the next token. (The watch page's own view tokens load an empty chat page.)
// A finished stream's chat replay works the same way on /live_chat_replay pages, but its
// requests (live_chat/get_live_chat_replay) are a cursor through the recorded chat: each gives
// the next chunk of messages, each stamped with its time in the video (`at`, ms), and the first
// token with another position starts again there (a seek).
// Turns YouTube's chat items into plain messages; knows nothing about VR.
//
// ns.youtubeChat.info() -> null | { replay, views: [{ title, selected }] }
// the watch page's chat (null when the video has none; replay: a finished stream's)
// await ns.youtubeChat.views(opts) -> [{ title, selected, token }] the views with the
// tokens that load them (from the chat frame, else the pop-out page; kept a minute per
// video); opts.skipFrame: ask the pop-out page (for checking it); opts.replay: a replay's
// await ns.youtubeChat.open(token, replay) -> { items, next, reactions, input, emojis, support, leaderboard }
// (replay: a chat replay's page, its `next` the start of the recorded chat)
// a view's chat page; reactions: the reaction buttons (❤ 😄 🎉 ...; [] when the stream has
// none, or signed out): [{ emoji (the character), label ("Send ❤"), token (what sends it) }];
// input: the message box, { placeholder, max (characters), emojiCost (characters an emoji
// counts for), params, prefix } or { restricted: why you can't chat }, or null (signed
// out); emojis: the channel's own [{ id, shortcut (':_wave:'), image, label }]; support:
// what its "$" menu offers ("Super Chat", "Gifts" ...); leaderboard: the Top fans button,
// { params (loads the list), pointsKey, xp (your XP: "120 XP", or null) }, or null
// await ns.youtubeChat.poll(next, pointsKey) -> { items, next, xp } what's new since `next`
// (xp: your XP when YouTube sent a new value, else undefined)
// await ns.youtubeChat.pollReplay(next, atMs) -> { items, next } a replay's next chunk
// (with the first token: the chunk from atMs on); its items carry `at`
// await ns.youtubeChat.topFans(params) -> { fans: [fan], you: fan | null } the Top fans
// list (fan: { rank ("1"), name, avatar, points ("30 XP"), badge, note (your row: YouTube's
// line under it) }; badge: { title ("#1"), bg, fg } | null)
// await ns.youtubeChat.react(reaction) -> true | false send one (YouTube's feedback request)
// await ns.youtubeChat.send(input, segments) -> true | false post a message: segments
// [{ text } | { emojiId }] (a channel emoji by its id)
// items: changes in order (in a replay, each with `at`: its time in the video, ms), each one of
// { op: 'add', msg } a new message (see below)
// { op: 'remove', id } a message taken down (deleted, or replaced by a removal)
// { op: 'removeAuthor', channelId } every message by someone (banned / timed out)
// { op: 'banner', banner } the pinned message: { id, header, msg }
// { op: 'unbanner', id } the pinned message taken down
// next: { token, timeoutMs } to poll with, or null when the chat has ended.
// msg: { id, kind ('text' | 'paid' | 'sticker' | 'member' | 'notice'), author, avatar,
// channelId, role ('owner' | 'moderator' | 'member' | null), verified, badge (a member
// badge's image URL or ''), rank (a Top fan's mark before the name: { title ("#1"), bg, fg }
// or null), runs: [{ text } | { emoji: { url, label } }], time (ms),
// amount ('$5.00', paid and sticker), header (a membership's line, e.g. "Member for 6
// months"), sticker (its image URL), colors ({ header, body, text } '#rrggbb', paid and
// sticker) }
(function (ns) {
'use strict';
const D = ns.youtubeData;
const { dig, findKey, strOf } = D;
// ---------------------------------------------------------------- the watch page's chat
// A chat's view selector -> [{ title, selected, token }] (only views with a token).
function viewsIn(data) {
const items = (findKey(data, 'sortFilterSubMenuRenderer') || {}).subMenuItems || [];
return items.map(v => ({ title: v.title || '', selected: !!v.selected, token: dig(findKey(v, 'reloadContinuationData'), 'continuation') || '' }))
.filter(v => v.token);
}
function info() {
const lc = findKey(D.watchData(), 'liveChatRenderer');
if (!lc) return null;
const views = viewsIn(lc).map(({ title, selected }) => ({ title, selected }));
return { replay: !!lc.isReplay, views: views.length ? views : [{ title: '', selected: true }] };
}
// The chat in a page's data, in either shape: a chat update's `liveChatContinuation` (the
// chat frame, a view's chat page) or `liveChatRenderer`; both have actions and continuations.
const chatIn = data => findKey(data, 'liveChatContinuation') || findKey(data, 'liveChatRenderer') || null;
let viewCache = null; // { video, at (ms), list }
const VIEW_CACHE_MS = 60000;
async function views(opts = {}) {
const video = D.currentVideoId();
if (!opts.skipFrame && viewCache && viewCache.video === video && Date.now() - viewCache.at < VIEW_CACHE_MS) return viewCache.list;
let list = [];
if (!opts.skipFrame) { // the chat frame YouTube shows beside a live stream (same origin)
try {
const f = document.querySelector('#chatframe, ytd-live-chat-frame iframe');
list = viewsIn(chatIn(f && f.contentWindow && f.contentWindow.ytInitialData));
} catch (_) { /* no frame, or not readable */ }
}
if (!list.length) list = viewsIn(chatIn(await page(`/live_chat${opts.replay ? '_replay' : ''}?is_popout=1&v=${encodeURIComponent(video)}`)));
if (!list.length) throw new Error('live_chat: no chat views found');
if (!opts.skipFrame) viewCache = { video, at: Date.now(), list };
return list;
}
// ---------------------------------------------------------------- requests
// The JSON object a page's HTML assigns to window["name"] (brace matching, skipping
// strings), or null.
function jsonAfter(text, name) {
const at = text.indexOf(`${name}"] = {`);
if (at < 0) return null;
const i = text.indexOf('{', at);
let depth = 0, inStr = false, esc = false;
for (let j = i; j < text.length; j++) {
const ch = text[j];
if (inStr) { if (esc) esc = false; else if (ch === '\\') esc = true; else if (ch === '"') inStr = false; continue; }
if (ch === '"') inStr = true;
else if (ch === '{') depth++;
else if (ch === '}' && --depth === 0) { try { return JSON.parse(text.slice(i, j + 1)); } catch (_) { return null; } }
}
return null;
}
// The poll token in a chat update: { token, timeoutMs } or null (the chat ended). In a
// replay, the cursor's token (liveChatReplayContinuationData), with `seek` too when YouTube
// gives a playerSeekContinuationData.
function nextOf(lcc) {
const list = (lcc && lcc.continuations) || [], c = list[0] || {};
const d = c.invalidationContinuationData || c.timedContinuationData || c.liveChatReplayContinuationData || c.reloadContinuationData || null;
if (!d || !d.continuation) return null;
const seek = list.map(x => x.playerSeekContinuationData).find(Boolean);
return { token: d.continuation, timeoutMs: +d.timeoutMs || 10000, ...(seek && seek.continuation ? { seek: seek.continuation } : {}) };
}
// A chat page's data (its ytInitialData), or null.
async function page(url) {
const res = await fetch(url, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`live_chat: HTTP ${res.status}`);
return jsonAfter(await res.text(), 'ytInitialData');
}
// Load a view's chat page: its recent messages (and pinned message), the token to poll with
// and the reaction buttons.
async function open(token, replay) {
const data = await page(`/live_chat${replay ? '_replay' : ''}?continuation=${encodeURIComponent(token)}`), lcc = chatIn(data);
if (!lcc) throw new Error('live_chat: no chat data in the page');
return { items: changes(lcc.actions), next: nextOf(lcc), reactions: reactionsIn(lcc), input: inputIn(lcc), emojis: emojisIn(lcc), support: supportIn(lcc),
leaderboard: leaderboardIn(lcc, data) };
}
// ---------------------------------------------------------------- Top fans
// The Top fans button in the chat's header (liveViewerLeaderboardChatEntryPointViewModel):
// the params that load its list, and your XP (an entity YouTube sends under its key).
function leaderboardIn(lcc, data) {
const entry = findKey(lcc && lcc.header, 'liveViewerLeaderboardChatEntryPointViewModel');
const params = entry && dig(findKey(entry, 'showEngagementPanelEndpoint'), 'globalConfiguration', 'params');
if (!params) return null;
return { params, pointsKey: entry.pointsEntityKey || '', xp: xpIn(data, entry.pointsEntityKey) || null };
}
// Your XP in a response's entity updates ("120 XP"), or undefined when it has none.
function xpIn(resp, key) {
const m = (dig(resp, 'frameworkUpdates', 'entityBatchUpdate', 'mutations') || [])
.find(x => x && x.payload && x.payload.liveViewerLeaderboardPointsEntity && (!key || x.entityKey === key));
return m ? m.payload.liveViewerLeaderboardPointsEntity.pointsCompactText || '' : undefined;
}
// A Top fan's mark (a crown button titled "#1" in YouTube's colours), or null.
function rankOf(buttons) {
const b = (buttons || []).map(x => x && x.buttonViewModel).find(x => x && x.title);
return b ? { title: b.title, bg: hex(b.customBackgroundColor), fg: hex(b.customFontColor) } : null;
}
// One row of the list (liveLeaderboardItemViewModel).
function fanOf(vm) {
if (!vm) return null;
const who = dig(vm, 'content', 'liveLeaderboardItemChannelContentViewModel') || {};
return { rank: dig(vm, 'rank', 'content') || '', points: dig(vm, 'points', 'content') || '', name: dig(who, 'displayName', 'content') || '',
avatar: dig(vm, 'avatar', 'avatarViewModel', 'image', 'sources', 0, 'url') || '', badge: rankOf([who.badge]),
note: dig(vm, 'content', 'liveLeaderboardItemActionContentViewModel', 'actionDescription', 'content') || '' };
}
// The list, as YouTube's Top fans panel loads it.
async function topFans(params) {
const r = await D.innertube('get_panel', { panelId: 'PAlive_viewer_leaderboard', params });
const panel = dig(r, 'content', 'engagementPanelSectionListRenderer') || {};
const rows = (dig(panel, 'content', 'sectionListRenderer', 'contents') || []).map(s => fanOf(findKey(s, 'liveLeaderboardItemViewModel'))).filter(Boolean);
return { fans: rows, you: fanOf(dig(panel, 'footer', 'liveLeaderboardItemViewModel')) };
}
// The message box (liveChatMessageInputRenderer: its field and send endpoint), or why you
// can't chat (whatever else the action panel holds: members only, sign in ...), or null.
function inputIn(lcc) {
const panel = lcc && lcc.actionPanel;
if (!panel) return null;
const box = panel.liveChatMessageInputRenderer;
const send = box && findKey(box.sendButton, 'sendLiveChatMessageEndpoint');
if (!box || !send || !send.params) return { restricted: strOf(findKey(panel, 'message')) || strOf(findKey(panel, 'text')) || '' };
const field = findKey(box, 'liveChatTextInputFieldRenderer') || {};
return { placeholder: strOf(field.placeholder), max: +field.maxCharacterLimit || 200, emojiCost: +field.emojiCharacterCount || 1,
params: send.params, prefix: send.clientIdPrefix || '' };
}
// The channel's own emojis (its members' ones): their ids, first shortcut and picture.
function emojisIn(lcc) {
return ((lcc && lcc.emojis) || []).filter(e => e.isCustomEmoji && e.emojiId && e.shortcuts && e.shortcuts[0])
.map(e => ({ id: e.emojiId, shortcut: e.shortcuts[0], image: thumb(e.image), label: e.shortcuts[0] }));
}
// What the "$" menu (the product picker) offers: its items' titles.
function supportIn(lcc) {
const picker = findKey(lcc && lcc.actionPanel, 'liveChatProductPickerPanelViewModel');
return ((picker && picker.items) || []).map(it => strOf(findKey(it, 'title') || findKey(it, 'text'))).filter(Boolean);
}
// Post a message; true when YouTube took it (its answer echoes it back as a chat item).
let sent = 0;
async function send(input, segments) {
const r = await D.innertube('live_chat/send_message', { params: input.params, clientMessageId: `${input.prefix}${Date.now()}${sent++}`,
richMessage: { textSegments: segments } });
return !!(r && !r.errors && (r.actions || []).some(a => a.addChatItemAction));
}
// The reaction buttons in a chat's message box (reactionControlPanelViewModel's
// expandedButtons): each one's emoji, label and feedback token.
function reactionsIn(lcc) {
const panel = findKey(lcc && lcc.actionPanel, 'reactionControlPanelViewModel') || {};
return (panel.expandedButtons || []).map(b => {
const v = b.reactionControlPanelButtonViewModel || {};
return { emoji: v.emojiId || '', label: v.a11yLabel || v.emojiId || '', token: dig(findKey(v.onTap, 'feedbackEndpoint'), 'feedbackToken') || '' };
}).filter(r => r.emoji && r.token);
}
// Send a reaction: YouTube's feedback request with its token. True when YouTube took it.
async function react(reaction) {
const r = await D.innertube('feedback', { feedbackTokens: [reaction.token], isFeedbackTokenUnencrypted: false, shouldMerge: false });
const answers = (r && r.feedbackResponses) || [];
return !answers.some(a => a && a.isProcessed === false);
}
// What's new since the last poll.
async function poll(next, pointsKey) {
const r = await D.innertube('live_chat/get_live_chat', { continuation: next.token });
const lcc = dig(r, 'continuationContents', 'liveChatContinuation');
return { items: changes(lcc && lcc.actions), next: nextOf(lcc), xp: xpIn(r, pointsKey) };
}
// A replay's next chunk, the player at atMs (with the replay's first token: the chunk from
// there on). Its changes carry `at`.
async function pollReplay(next, atMs) {
const r = await D.innertube('live_chat/get_live_chat_replay', { continuation: next.token,
currentPlayerState: { playerOffsetMs: String(Math.max(0, Math.round(atMs || 0))) } });
const lcc = dig(r, 'continuationContents', 'liveChatContinuation');
return { items: changes(lcc && lcc.actions), next: nextOf(lcc) };
}
// ---------------------------------------------------------------- reading items
// ARGB number -> '#rrggbb' ('' for none).
const hex = n => (typeof n === 'number' ? `#${((n >>> 0) & 0xffffff).toString(16).padStart(6, '0')}` : '');
const usec = t => (t ? Math.round(+t / 1000) : 0);
const thumb = obj => { const l = (obj && obj.thumbnails) || []; const t = l[l.length - 1]; return t && t.url ? t.url.replace(/^\/\//, 'https://') : ''; };
// A message's text pieces: words as they are; an emoji as its picture (a channel's own
// emoji) or as its character (a standard one, drawn by the font).
function runsOf(t) {
if (!t) return [];
if (!t.runs) { const s = strOf(t); return s ? [{ text: s }] : []; }
return t.runs.map(r => {
if (!r.emoji) return { text: r.text || '' };
const e = r.emoji;
if (!e.isCustomEmoji && e.emojiId && !/\//.test(e.emojiId)) return { text: e.emojiId };
return { emoji: { url: thumb(e.image), label: (e.shortcuts && e.shortcuts[0]) || strOf(dig(e, 'image', 'accessibility', 'accessibilityData', 'label')) || '' } };
}).filter(r => r.emoji || r.text);
}
// Who wrote it: name, picture, channel, and what the badges say (owner, moderator, member
// with the member badge's picture, verified).
function authorOf(r) {
const a = { author: strOf(r.authorName), avatar: thumb(r.authorPhoto), channelId: r.authorExternalChannelId || '', role: null, verified: false, badge: '' };
for (const b of r.authorBadges || []) {
const lb = b.liveChatAuthorBadgeRenderer || {};
const type = dig(lb, 'icon', 'iconType') || '';
if (type === 'OWNER') a.role = 'owner';
else if (type === 'MODERATOR') { if (a.role !== 'owner') a.role = 'moderator'; }
else if (type === 'VERIFIED') a.verified = true;
else if (lb.customThumbnail) { a.badge = thumb(lb.customThumbnail); if (!a.role) a.role = 'member'; }
}
return a;
}
// One chat item -> a message, or null for what isn't shown (placeholders: messages YouTube
// holds back; anything unknown).
function messageOf(item) {
const key = item && Object.keys(item)[0], r = key && item[key];
if (!r) return null;
const base = { id: r.id || '', time: usec(r.timestampUsec) };
switch (key) {
case 'liveChatTextMessageRenderer':
return { ...base, kind: 'text', ...authorOf(r), rank: rankOf(r.beforeContentButtons), runs: runsOf(r.message) };
case 'liveChatPaidMessageRenderer':
return { ...base, kind: 'paid', ...authorOf(r), runs: runsOf(r.message), amount: strOf(r.purchaseAmountText),
colors: { header: hex(r.headerBackgroundColor), body: hex(r.bodyBackgroundColor), text: hex(r.bodyTextColor) || hex(r.headerTextColor) } };
case 'liveChatPaidStickerRenderer':
return { ...base, kind: 'sticker', ...authorOf(r), runs: [], amount: strOf(r.purchaseAmountText), sticker: thumb(r.sticker),
colors: { header: hex(r.backgroundColor), body: hex(r.backgroundColor), text: hex(r.authorNameTextColor) || hex(r.moneyChipTextColor) } };
case 'liveChatMembershipItemRenderer':
return { ...base, kind: 'member', ...authorOf(r), runs: runsOf(r.message), header: strOf(r.headerPrimaryText) || strOf(r.headerSubtext) };
case 'liveChatSponsorshipsGiftPurchaseAnnouncementRenderer': {
const h = dig(r, 'header', 'liveChatSponsorshipsHeaderRenderer') || {};
return { ...base, kind: 'member', ...authorOf(h), runs: [], header: strOf(h.primaryText) };
}
case 'liveChatSponsorshipsGiftRedemptionAnnouncementRenderer':
return { ...base, kind: 'member', ...authorOf(r), runs: runsOf(r.message), header: '' };
case 'liveChatViewerEngagementMessageRenderer':
case 'liveChatModeChangeMessageRenderer':
return { ...base, kind: 'notice', author: '', avatar: '', channelId: '', role: null, verified: false, badge: '',
runs: [...runsOf(r.message || r.text), ...(r.subtext ? [{ text: ` ${strOf(r.subtext)}` }] : [])] };
default:
return null; // placeholders and anything new
}
}
// A chat update's actions -> the changes they make (see the top of the file), in order.
function changes(actions) {
const out = [];
for (const a of actions || []) {
if (a.replayChatItemAction) { // a replay's: stamped with its time in the video
const at = +a.replayChatItemAction.videoOffsetTimeMsec || 0;
for (const ch of changes(a.replayChatItemAction.actions)) out.push({ ...ch, at });
continue;
}
if (a.addChatItemAction) {
const msg = messageOf(a.addChatItemAction.item);
if (msg && msg.id) out.push({ op: 'add', msg });
} else if (a.markChatItemAsDeletedAction || a.removeChatItemAction) {
const id = (a.markChatItemAsDeletedAction || a.removeChatItemAction).targetItemId;
if (id) out.push({ op: 'remove', id });
} else if (a.markChatItemsByAuthorAsDeletedAction || a.removeChatItemByAuthorAction) {
const channelId = (a.markChatItemsByAuthorAsDeletedAction || a.removeChatItemByAuthorAction).externalChannelId;
if (channelId) out.push({ op: 'removeAuthor', channelId });
} else if (a.replaceChatItemAction) {
const ra = a.replaceChatItemAction, msg = messageOf(ra.replacementItem);
if (msg && msg.id) out.push({ op: 'remove', id: ra.targetItemId }, { op: 'add', msg });
} else if (a.addBannerToLiveChatCommand) {
const b = dig(a.addBannerToLiveChatCommand, 'bannerRenderer', 'liveChatBannerRenderer') || {};
const msg = messageOf(b.contents); // a pinned message (a chat summary isn't a message: skipped)
if (msg && b.bannerType !== 'LIVE_CHAT_BANNER_TYPE_CHAT_SUMMARY') {
out.push({ op: 'banner', banner: { id: b.actionId || msg.id, header: strOf(dig(b, 'header', 'liveChatBannerHeaderRenderer', 'text')), msg } });
}
} else if (a.removeBannerForLiveChatCommand) {
out.push({ op: 'unbanner', id: a.removeBannerForLiveChatCommand.targetActionId || '' });
}
}
return out;
}
ns.youtubeChat = { info, views, open, poll, pollReplay, react, send, topFans, changes };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+1
View File
@@ -241,6 +241,7 @@
ns.vrKeyboardOptions.reset();
ns.vrAppearanceOptions.reset();
ns.vrSafeZone.reset();
ns.vrSafeZoneOptions.reset();
ns.vrFadeOptions.reset();
ns.i18n.setChoice('auto');
ns.storage.remove('passthrough'); // the popup's checkbox (next session)