15 Commits
Author SHA1 Message Date
Fauxx fa8441352c Release v0.93.3: 3D side by side, YouTube Premium, your Channels and the notification bell (#6)
## What and why

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

Everything since v0.88.1, partly from feedback by
[u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack/)
on Reddit and [@damics80](https://x.com/damics80) on X:

- **3D side by side** (asked for by [@damics80](https://x.com/damics80);
the VR180 case spotted by
[u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack/)):
**180° 3D side by side** and **Cinema 3D side by side** in the Format
menu, each eye seeing its half of the frame.
- **YouTube Premium:** **1080p Premium** in the Quality menu and **Jump
ahead** at the seek bar's end, both with the Diamond.
- **Subscriptions:** You → **Channels** with YouTube's sort choices, and
the **notification bell** on the Channel panel.
- **Format menu:** every format with its icon; the button always says
"Format".
- **Fixes:**
- videos open from the Channel panel and History when VR started on a
channel page
  - menus no longer cover the pointing ring
  - the Channels list loads all the way down
- **Docs:**
  - decision 0002 (StreamXR never bypasses the site's player)
- the README's "What's new" and roadmap (Chrome Web Store as 1.0, then
Twitch, Jellyfin / Plex, music streaming and an experimental barebones
mode)
  - Meta Quest 2 reported working

The full list is in `CHANGELOG.md` (0.89.0 to 0.93.3); the details of
each change are in the `Feature-2-Premium-and-Subscrptions` → `develop`
pull request.

## How

See the feature branch's pull request. In short:
- per-eye cropping in the sphere, the cinema screen and the glass
(`formats.eyeSpans`)
- YouTube's Premium quality entry chosen by its `formatId`, and Jump
ahead clicking YouTube's own pill
- the Manage page's shelves and sort choices read by
`youtube-subscriptions.js`
- new generic shell pieces: side-panel menus, round cards, coloured
icons, a seek-row button slot
- in-page navigation sent from `ytd-page-manager`

No new permissions.

## Tested on

- Headset and controllers: Valve Steam Frame with its controllers
- VR runtime / connection: SteamVR, "Stream VR without game"
- Browser and version: Google Chrome 155.0.8059.82 (Official Build)
(64-bit) (cohort: Control)
- Signed in to YouTube: yes, with YouTube Premium
- What I tried in VR: each version from 0.89.0 to 0.93.3 as it was
built:
  - both 3D formats, with each eye and the glass
  - 1080p Premium
  - Jump ahead
  - You → Channels with its pills and the Purchased flag
  - the bell
  - opening videos from a channel page
  - the menus
- Not yet: Skip ad taking Jump ahead's spot during an ad (needs a
non-Premium view).

## 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`: 4 commits, 38
files (+1051 −193).

- ⚠️ No linked issue: add `Closes #<issue>` under "What and why" (an
agreed issue is required).
- ✅ Version: 0.88.1 → 0.93.3
- ✅ `CHANGELOG.md` updated
- ⚠️ The shell should name no site, but added lines mention YouTube in:
`src/streamxr/shared/formats.js` (fine in comments giving an example;
otherwise move it to the provider).

#### Commits (4)

| Commit | Message | Author |
|---|---|---|
|
[`8078578`](https://github.com/FauxxYT/StreamXR/commit/80785784c9384a76602b886ecf5efc39d9565fad)
| Update documentation with some feedback and a new design decision for
not bypassing the YouTube player | @FauxxYT |
|
[`fd80f53`](https://github.com/FauxxYT/StreamXR/commit/fd80f536218c4bb8156effc7bfaa412d18494606)
| Interim Commit before finishing features | @FauxxYT |
|
[`ef539f9`](https://github.com/FauxxYT/StreamXR/commit/ef539f9a39f378a154b0d5a6c2c4f51a767e3ac4)
| Added: 3D Side-By-Side support for VR180 and 2D Cinema, YouTube
Premium (high bitrate 1080p and Jump Ahead), Your Channels, Notification
Bell on Channel Panel, and tidied up Format menu on Control panel |
@FauxxYT |
|
[`b554542`](https://github.com/FauxxYT/StreamXR/commit/b554542559d3e07a99639a1fe7fd86fb3a1c639d)
| v0.93.3: 3D side by side, YouTube Premium, your Channels and the
notification bell (#5) | @FauxxYT |

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

- ✏️ `AGENTS.md` +4 −1
- ✏️ `ARCHITECTURE.md` +10 −3
- ✏️ `CHANGELOG.md` +146 −0
- ✏️ `README.md` +51 −44
- 🆕 `docs/decisions/0002-site-player-only.md` +70 −0
- ✏️ `docs/design-choices.md` +17 −0
- ✏️ `docs/features/subscriptions-page.md` +16 −3
- ✏️ `docs/features/video-page.md` +19 −4
- ✏️ `docs/known-issues.md` +1 −1
- ✏️ `docs/whats-left.md` +62 −14
- ✏️ `docs/youtube.md` +40 −4
- ✏️ `manifest.json` +1 −1
- ✏️ `src/core/page/vr-panel/panel-pass.js` +21 −11
- ✏️ `src/core/page/vr-panel/panel-ui.js` +50 −14
- ✏️ `src/core/page/vr-panel/popups-ui.js` +11 −4
- ✏️ `src/core/page/vr-panel/styles/kit.js` +8 −2
- ✏️ `src/core/page/vr-panel/styles/profile.js` +12 −3
- ✏️ `src/core/page/vr-panel/symbols.js` +29 −1
- ✏️ `src/core/page/vr-panel/vr-panel.js` +42 −6
- ✏️ `src/sites/youtube/page/provider/browse.js` +39 −6
- ✏️ `src/sites/youtube/page/provider/channel.js` +65 −4
- ✏️ `src/sites/youtube/page/provider/extras.js` +18 −4
- ✏️ `src/sites/youtube/page/provider/panels.js` +7 −1
- ✏️ `src/sites/youtube/page/provider/provider.js` +26 −2
- ✏️ `src/sites/youtube/page/provider/strings-en.js` +5 −0
- ✏️ `src/sites/youtube/page/provider/strings-es.js` +5 −0
- ✏️ `src/sites/youtube/page/youtube-channel.js` +40 −1
- ✏️ `src/sites/youtube/page/youtube-page.js` +6 −3
- ✏️ `src/sites/youtube/page/youtube-player.js` +64 −11
- ✏️ `src/sites/youtube/page/youtube-subscriptions.js` +78 −10
- ✏️ `src/streamxr/page/cinema-screen.js` +26 −12
- ✏️ `src/streamxr/page/video-sphere.js` +12 −7
- ✏️ `src/streamxr/shared/formats.js` +17 −8
- ✏️ `src/streamxr/shared/strings-en.js` +4 −0
- ✏️ `src/streamxr/shared/strings-es.js` +4 −0
- ✏️ `src/streamxr/ui/shared/formats.js` +17 −8
- ✏️ `src/streamxr/ui/shared/strings-en.js` +4 −0
- ✏️ `src/streamxr/ui/shared/strings-es.js` +4 −0

</details>
<!-- pr-summary:end -->
2026-10-17 19:53:32 -07:00
Fauxx b554542559 v0.93.3: 3D side by side, YouTube Premium, your Channels and the notification bell (#5)
## What and why

Feature branch for the next release (v0.89.0 to v0.93.3), into
`develop`. No issue: the maintainer's own work, partly from feedback by
[u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack/)
on Reddit and [@damics80](https://x.com/damics80) on X.

- **3D side by side** (asked for by [@damics80](https://x.com/damics80);
the VR180 case spotted by
[u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack/)):
two new formats, **180° 3D side by side** (VR180) and **Cinema 3D side
by side** (regular 3D videos on the cinema screen), each eye seeing its
half of the frame, the glass and the screen's glow included.
- **YouTube Premium:** **1080p Premium** in the Quality menu and **Jump
ahead** at the seek bar's end (shown while YouTube offers it), both with
the Diamond.
- **Subscriptions:** You → **Channels** (your subscriptions, with
YouTube's sort choices as pills and a flag for "Purchased" channels) and
the **notification bell** on the Channel panel.
- **Format menu:** every format with its icon (`2d_2`, `3d_2` and the
rest); the button always says "Format".
- **Fixes:**
- videos didn't open from the Channel panel or History when VR started
on a channel page
  - menu rows covered the pointing ring of the row next to them
  - the Channels list stopped after its first page
- **Docs:**
  - decision 0002 (StreamXR never bypasses the site's player, and why)
  - the Premium audit
- the README's "What's new" and roadmap (Chrome Web Store as 1.0;
Twitch, Jellyfin / Plex, music streaming and an experimental barebones
mode after it)
- Meta Quest 2 reported working
([u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack/))

## How

- **3D:** `formats.js` gains `flatsbs` / `180sbs` and `eyeSpans()`.
`video-sphere.js` and `cinema-screen.js` crop each eye's half
(`uUvXform` / `uUvX`), and `panel-pass.js` samples the glass backdrop
per eye (`uvX`). The cinema screen takes one eye's shape: half-SBS 16:9
stays 16:9, two full pictures (wider than 2.5:1) get half the frame's
width.
- **Premium:** `youtube-player.js` `quality()` keeps the Premium entry
apart (its own `formatId`), `setQuality('premium')` picks it, and
`hasPremium()` reads the top bar's logo. `jumpAhead()` clicks YouTube's
own Jump ahead pill (a script-sent Ctrl + → didn't work from VR).
- **Subscriptions:** `youtube-subscriptions.js` `channels(sort)` reads
the Manage page's shelves and sort choices (`channelsOn`, `sortsOf`);
`youtube-channel.js` `bellOf` reads a channel's bell.
- **Shell additions (all generic, no site fields):**
- floating menus on side panels: `host.openMenu`, the controller's
`menu()`
  - `host.panel(id)`
  - the Profile style's `iconAction` and `rectOf`
  - round cards: `fit: 'round'`
  - icons with their own colour: `drawIcon`
  - a seek-row button slot: `extras.seek`
  - `disabled` quality levels
  - the button row narrowing when it's full
- **Navigation:** `yt-navigate` is now sent from `ytd-page-manager`
(from `ytd-app` it was ignored on channel pages).

## Tested on

- Headset and controllers: Valve Steam Frame with its controllers
- VR runtime / connection: SteamVR, "Stream VR without game"
- Browser and version: Google Chrome <!-- your version, from
chrome://version -->
- Signed in to YouTube: yes, with YouTube Premium
- What I tried in VR:
  - both 3D formats, with each eye and the glass
  - 1080p Premium on and off
- Jump ahead (shown only with YouTube's pill, greyed out while paused,
absent on live replays)
  - You → Channels, its sort pills and the Purchased flag
  - the bell, from the Channels list and from the playing video
  - opening videos from a channel page
  - the Format and bell menus
- Not yet: Skip ad taking Jump ahead's spot during an ad (needs a
non-Premium view).

## 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. *(Maintainer's feature branch; 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-2-Premium-and-Subscrptions`
into `develop`: 3 commits, 38 files (+1051 −193).

- ⚠️ No linked issue: add `Closes #<issue>` under "What and why" (an
agreed issue is required).
- ✅ Version: 0.88.1 → 0.93.3
- ✅ `CHANGELOG.md` updated
- ⚠️ The shell should name no site, but added lines mention YouTube in:
`src/streamxr/shared/formats.js` (fine in comments giving an example;
otherwise move it to the provider).

#### Commits (3)

| Commit | Message | Author |
|---|---|---|
|
[`8078578`](https://github.com/FauxxYT/StreamXR/commit/80785784c9384a76602b886ecf5efc39d9565fad)
| Update documentation with some feedback and a new design decision for
not bypassing the YouTube player | @FauxxYT |
|
[`fd80f53`](https://github.com/FauxxYT/StreamXR/commit/fd80f536218c4bb8156effc7bfaa412d18494606)
| Interim Commit before finishing features | @FauxxYT |
|
[`ef539f9`](https://github.com/FauxxYT/StreamXR/commit/ef539f9a39f378a154b0d5a6c2c4f51a767e3ac4)
| Added: 3D Side-By-Side support for VR180 and 2D Cinema, YouTube
Premium (high bitrate 1080p and Jump Ahead), Your Channels, Notification
Bell on Channel Panel, and tidied up Format menu on Control panel |
@FauxxYT |

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

- ✏️ `AGENTS.md` +4 −1
- ✏️ `ARCHITECTURE.md` +10 −3
- ✏️ `CHANGELOG.md` +146 −0
- ✏️ `README.md` +51 −44
- 🆕 `docs/decisions/0002-site-player-only.md` +70 −0
- ✏️ `docs/design-choices.md` +17 −0
- ✏️ `docs/features/subscriptions-page.md` +16 −3
- ✏️ `docs/features/video-page.md` +19 −4
- ✏️ `docs/known-issues.md` +1 −1
- ✏️ `docs/whats-left.md` +62 −14
- ✏️ `docs/youtube.md` +40 −4
- ✏️ `manifest.json` +1 −1
- ✏️ `src/core/page/vr-panel/panel-pass.js` +21 −11
- ✏️ `src/core/page/vr-panel/panel-ui.js` +50 −14
- ✏️ `src/core/page/vr-panel/popups-ui.js` +11 −4
- ✏️ `src/core/page/vr-panel/styles/kit.js` +8 −2
- ✏️ `src/core/page/vr-panel/styles/profile.js` +12 −3
- ✏️ `src/core/page/vr-panel/symbols.js` +29 −1
- ✏️ `src/core/page/vr-panel/vr-panel.js` +42 −6
- ✏️ `src/sites/youtube/page/provider/browse.js` +39 −6
- ✏️ `src/sites/youtube/page/provider/channel.js` +65 −4
- ✏️ `src/sites/youtube/page/provider/extras.js` +18 −4
- ✏️ `src/sites/youtube/page/provider/panels.js` +7 −1
- ✏️ `src/sites/youtube/page/provider/provider.js` +26 −2
- ✏️ `src/sites/youtube/page/provider/strings-en.js` +5 −0
- ✏️ `src/sites/youtube/page/provider/strings-es.js` +5 −0
- ✏️ `src/sites/youtube/page/youtube-channel.js` +40 −1
- ✏️ `src/sites/youtube/page/youtube-page.js` +6 −3
- ✏️ `src/sites/youtube/page/youtube-player.js` +64 −11
- ✏️ `src/sites/youtube/page/youtube-subscriptions.js` +78 −10
- ✏️ `src/streamxr/page/cinema-screen.js` +26 −12
- ✏️ `src/streamxr/page/video-sphere.js` +12 −7
- ✏️ `src/streamxr/shared/formats.js` +17 −8
- ✏️ `src/streamxr/shared/strings-en.js` +4 −0
- ✏️ `src/streamxr/shared/strings-es.js` +4 −0
- ✏️ `src/streamxr/ui/shared/formats.js` +17 −8
- ✏️ `src/streamxr/ui/shared/strings-en.js` +4 −0
- ✏️ `src/streamxr/ui/shared/strings-es.js` +4 −0

</details>
<!-- pr-summary:end -->
2026-10-17 19:51:10 -07:00
Fauxx ef539f9a39 Added: 3D Side-By-Side support for VR180 and 2D Cinema, YouTube Premium (high bitrate 1080p and Jump Ahead), Your Channels, Notification Bell on Channel Panel, and tidied up Format menu on Control panel 2026-10-17 19:36:41 -07:00
Fauxx fd80f53621 Interim Commit before finishing features 2026-10-17 12:16:17 -07:00
Fauxx 80785784c9 Update documentation with some feedback and a new design decision for not bypassing the YouTube player 2026-10-17 11:20:34 -07:00
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
55 changed files with 3100 additions and 329 deletions

No files matched your search

+9 -6
View File
@@ -18,6 +18,9 @@ for the code rules.
## Never
- Add a library, a package manager, a build step or ES modules (plain scripts only).
- Add a permission or a host to `manifest.json` without the maintainer agreeing to it first.
- Bypass the site's own player (fetching its streams yourself, posing as another client) to
get 3D, resolutions or anything else: StreamXR draws what the site's player plays. Point
such requests to [docs/decisions/0002](docs/decisions/0002-site-player-only.md) and its risks.
- Name a site (YouTube or any other) in `src/core/` or `src/streamxr/`: site code goes behind
its provider in `src/sites/<site>/`.
- Write on-screen words in the code: they go in the string tables (see "Where things go").
@@ -109,13 +112,13 @@ 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`),
registered in `panels.js`; the controls panel's Captions and Save buttons are `extras.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, Jump ahead 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).
- Tunables: `vr-panel/config.js`, `controllers/config.js`, `SETTINGS` in `cinema-screen.js`,
+34 -11
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")
extras.js the controls panel's Captions and Save buttons and Jump ahead at the seek bar's end (the shell's "extras": left, right, seek), premiumMark() (the Diamond)
strings-en.js the YouTube panels' words in English (strings-<lang>.js: other languages)
cards.js, badges.js YouTube items -> generic cards; the ✓ / ♪ badges
upnext.js, browse.js (Explore and You), search.js, channel.js, description.js, comments-panel.js, settings.js
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,36 @@ 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(), openMenu(control), panel(id), confirm(d), typeInto(on, opts) }`
(`opts.emojis`: the panel's own emojis, `[{ text, image, label }]`, first on the keyboard's emoji pages);
`confirm(d)` asks in the shell's dialog (`{ title, text (up to four lines), confirm, cancel
(null: no cancel button, a dialog that only informs), danger }` -> a Promise
of true / false); the site's `onControl(id, panel, shell)` gets the same as `shell.confirm`.
- Menus on side panels: `host.openMenu(control)` (e.g. `'action:bell'`) opens a floating menu
above that control, 32 dp in front, like the controls panel's (the style needs `rectOf(id)`;
Profile has it). While it's up, `vr-panel.js` asks the controller's `menu(control)` each frame
(`{ items: [{ id, text, on, disabled, icons }], note, stay }`, `null` closes it); a choice
arrives as `{ type: 'menu', value: item id, menu: control }`. The menu's id is
`'p:<panel>|<control>'` and its items `'pi:<index>'` (`panelMenu`). `host.panel(id)` is another
panel's controller (the You panel opens a channel in the Channel panel with it).
- Typing: a panel with a text field calls `host.typeInto(true)` to get the keys (and
`false` to give them back). 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,10 +323,10 @@ 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) |
| **Profile** (`profile.js`) | Channel | banner, picture, name + badge, stats line, one action button (looks: primary / secondary / danger / busy) and an optional round icon button beside it (the bell), tabs (shrinking to fit beside the pills), pills, a search button (searching: a back button and a text field in place of the tabs), card grid, paging, or one featured card, or a scrolling body: a text `page` (paragraphs, then label / value facts), a `feed` of posts (picture, author, text, pictures, a card, a poll with result bars, buttons with icons and counts), or one opened `post` with a back button and its comments (thread.js) |
| **Options** (`options.js`) | Settings | tabs (with icons); up to 3 rows (name with its hint under it; at the right: a switch, a slider with a Default button and end captions, a ‹ value › stepper, or a button; faded) or a 4 x 4 choice grid with "More" paging |
Each style file's header lists its model and control ids. Every style returns
@@ -312,7 +335,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:
+329 -6
View File
@@ -6,13 +6,332 @@ 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.93.3
### 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 cinema screen's and 3D Cinema's icons are Material Symbols' **2d_2** and **3d_2** (the
plain "2D" / "3D" lettering).
## 0.93.2
### Changed
- **The Format button** always says **Format**; its icon shows the chosen format (the format's
name is in the menu).
- The cinema screen's icon is Material Symbols' **2d** (3D Cinema keeps `3d`).
- The Format menu names Cinema screen 3D side by side "Cinema 3D / side by side" again.
## 0.93.1
### Changed
- **The Format menu** shows each format's icon on the left of its row (as the Quality menu
does), the same icons as the Format button.
- Cinema screen 3D side by side is called **3D Cinema** on the Format button (the longer name
was too tight).
## 0.93.0
### Added
- **Cinema screen 3D side by side:** a new format for regular (not VR180) 3D videos that put
both eyes in one frame: the cinema screen gives each eye its half, in 3D, with the `3d` icon
on the Format button (180° 3D side by side keeps its own). The screen takes one eye's shape:
a 16:9 frame with each eye squeezed into half ("half side by side", the usual upload) shows
at 16:9, a frame of two full pictures (wider than 2.5:1) at half its width. The glow and the
glass behind the panels show each eye its own half too. Picked by hand in the Format menu.
## 0.92.3
### Fixed
- **Jump ahead** now follows YouTube's own Jump ahead pill: the button at the seek bar's end
shows only while YouTube offers it (so not on videos without it, live streams or their
replays), and clicking it clicks YouTube's pill, which jumps (the keyboard shortcut it sent
before did nothing from VR). Greyed out while paused.
## 0.92.2
### Fixed
- **Videos didn't open from the Channel panel or History** while YouTube's page was on a
channel page (VR started there, nothing playing yet): YouTube ignored the in-page
navigation there. It's now sent from inside the page, which works on channel pages, Home
and the watch page alike.
- **Floating menus:** the ticked row covered the pointing ring of the row next to it; the
rings are drawn after all the rows now.
- **You → Channels sort pills** still didn't show: the sort choices sit deeper in YouTube's
answer than the reader looked; it searches all the way down now.
## 0.92.1
### Changed
- **Jump ahead** moved to the end of the seek bar, where Skip ad shows during ads (the
maintainer's choice): a button with the Diamond, "Jump ahead" and fast-forward; the seek bar
shortens for it. It's hidden on live streams and greyed out while paused. (A generic slot:
a site's `extras.seek` button, panel-ui.js.)
## 0.92.0
### Added
- **Jump ahead** (YouTube Premium) on the controls panel, right after +10 s: fast-forward over
the **Diamond**. It jumps to the point most viewers skip to, as YouTube's own Jump ahead
does (it sends YouTube's desktop shortcut, Ctrl + →, to the player). Usable while a video
plays; not on live streams or during an ad. Without Premium it's greyed out with the purple
Diamond. On videos YouTube doesn't offer Jump ahead for (Shorts, music, movies...) it does
nothing.
### Changed
- The controls panel's buttons narrow a little when the site adds more buttons than fit the
row, so it keeps its margins (one more site button on the right, Jump ahead, made it so).
## 0.91.0
### Added
- **1080p Premium** in the Quality menu (YouTube Premium's enhanced-bitrate 1080p, on videos
uploaded at 1080p): its own row just above the plain 1080p, marked with the **Diamond** in
YouTube red. Without Premium the row shows a purple Diamond and is greyed out (as YouTube's
own menu upsells it). While it plays, the Quality button shows Full HD with the Diamond.
- For other sites (ARCHITECTURE.md): an icon can carry its own colour (`{ name, color,
filled }`: symbols.js `drawIcon`), in menu rows and on the Quality button; a quality level
can be `disabled`.
## 0.90.3
### Fixed
- **You → Channels:** the sort pills (Most relevant, New activity, A-Z) and the section flags
(e.g. **Purchased**) never showed: they're read now from where YouTube's page really keeps
them (the sort choices as list items that reload the list, the sections as titled shelves).
## 0.90.2
### Removed
- **The Posts pill in You → Subscriptions:** desktop YouTube's Subscriptions feed doesn't mix
community posts in (checked signed in), so it always came up empty. Posts are still on each
channel's Posts tab.
## 0.90.1
### Added
- **Channels sort pills:** You → Channels offers YouTube's own sort choices for the list (Most
relevant, New activity, A-Z), the one it's sorted by chosen. Channels YouTube lists in a
section of their own (e.g. **Purchased**, a channel you're a member of) carry that
section's name as a flag.
### Fixed
- **3D side by side:** the glass panels showed the left eye's picture behind them in both
eyes; each eye now sees its own half behind the glass too.
- **Floating menus** (the bell's, Format, Quality...) cut off the pointing ring round their
first and last rows.
- The Channels list could stop after its first page when YouTube splits it into sections.
## 0.90.0
### Added
- **Your channels** (You → **Channels**, signed in): the channels you subscribe to, A to Z, with
their round pictures, handle · subscribers and their notification setting. Click one to
open it in the Channel panel.
- **The notification bell** on the Channel panel: once you're subscribed, a round bell button
beside Subscribed opens a small menu with YouTube's choices (All, Personalized, None), the
current one ticked; its icon shows which is on. The tick moves only when YouTube confirms.
- **Posts from your subscriptions:** You → Subscriptions has two pills, **Videos** (the feed)
and **Posts** (the community posts mixed into it). A post that shares a video plays it;
any other opens its channel's Posts tab.
- For other sites' panels (ARCHITECTURE.md): side panels can open a floating menu from one of
their controls (`host.openMenu(control)`, the controller's `menu(control)`, choices as
`{ type: 'menu' }` events) and reach another panel's controller (`host.panel(id)`); the
Profile style has a round icon button beside its action button (`iconAction`) and
`rectOf(id)`; a card can show a round picture (`fit: 'round'`). New icons: `diamond`,
`fast_forward`, `notifications_off`.
## 0.89.0
### Added
- **3D side-by-side videos:** a new format, **180° 3D side by side**, in the Format menu (and
the video page's format picker). Some VR180 videos reach the browser with both eyes in one
frame (left eye's picture on the left), which used to be stretched over the half sphere as
one picture; this format gives each eye its half, in 3D. YouTube's data doesn't mark these
videos, so pick it by hand when a VR180 video shows two pictures side by side. (Most 3D
videos reach desktop browsers as one eye only and still play flat:
[decisions/0002](docs/decisions/0002-site-player-only.md).)
- For other renderers: the glass backdrop's `uvX` option (the part of the frame a sphere
backdrop covers, across), `formats.eyeSpans(fmt)`.
## 0.88.1
### 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 +345,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
+80 -26
View File
@@ -4,18 +4,39 @@ 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-v0933) · [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.93). 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.93.3
The latest release, in short (every version's details are in the [changelog](CHANGELOG.md)):
- 🕶️ **3D side-by-side videos:** some 3D videos reach your browser with both eyes in one frame.
Pick **180° 3D side by side** (VR180) or **Cinema 3D side by side** (regular 3D videos on the
cinema screen) in the Format menu, and each eye gets its own half, in real 3D. Thanks to
[u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack) for spotting them!
- 💎 **YouTube Premium in VR:** **1080p Premium** (the enhanced bitrate) in the Quality menu, and
**Jump ahead** at the end of the seek bar whenever YouTube offers it, both marked with the
Diamond.
- 👥 **Your channels:** You → **Channels** lists the channels you subscribe to, sorted your way
(Most relevant, New activity, A-Z). Click one to open it in the Channel panel.
- 🔔 **The notification bell:** on a channel you subscribe to, choose All, Personalized or None
right from the Channel panel.
- 🎚️ **A tidier Format menu:** every format with its own icon, and the Format button shows the
one you picked.
- 🩹 **Fixes:** videos open from the Channel panel and History even when VR started on a
channel page; floating menus no longer clip the pointing ring; the Channels list loads all
the way down.
## 💡 Why I made this
Two reasons:
@@ -82,7 +103,8 @@ more likely they are to open the door. **If and when they do, I'll make it work
## 🌟 What you get
### 🎥 Every kind of video, the way it was meant to be seen
- **360° and VR180** wrap around you (YouTube's cubemap "mesh" format included); the format
is detected automatically and can be changed any time.
is detected automatically and can be changed any time. **3D side-by-side** videos play in
real 3D, as VR180 or on the cinema screen (pick it in the Format menu).
- **Regular videos** play on a **cinema screen** you can **curve**, **resize**, **move** and
**push away**, with **rounded corners** and an optional **ambient glow** that spills the
picture's colors around it, like a TV with backlighting. 🌈
@@ -97,7 +119,8 @@ more likely they are to open the door. **If and when they do, I'll make it work
- Title, views, date and the channel, with **Subscribe**, **Like** and **Dislike**.
- A **seek bar** with chapter marks, a **preview frame**, YouTube's **Most Replayed** curve
and how much has **loaded**.
- **Quality** (8K, 4K, 60 fps...), **speed**, **volume**, **format**, **recenter** and exit.
- **Quality** (8K, 4K, 60 fps, and **1080p Premium** 💎 with Premium), **speed**, **volume**,
**format**, **recenter** and exit; **Jump ahead** 💎 at the seek bar's end when YouTube offers it.
- **💬 Captions** in any language the video has, drawn right in VR.
- **🔖 Save** to Watch later or any of your playlists.
- Playlists get **Previous / Next** with a preview card; live streams get a **LIVE** label
@@ -111,8 +134,14 @@ glassy toolbars on each side:
and **Channel** (videos, Shorts, live streams, playlists, posts, About, Subscribe).
- 🔎 **Search** above the video, with suggestions as you type on the **VR keyboard**.
- ➡️ **What to watch:** **Up Next**, **Explore** (your Home feed with all its topic chips,
"Immersive videos" included, plus News) and **You** (Subscriptions, Your channel,
"Immersive videos" included, plus News) and **You** (Subscriptions, your Channels with their
notification bells, 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 +206,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 +256,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,15 +315,18 @@ 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
CC BY, CC BY-SA, CC BY-NC-SA or CC0), like the Steam Frame models' (CC BY-NC-SA 4.0).
**🎬 Video**
- **No 3D (stereo) videos.** On desktop, YouTube only sends one eye's picture, so 3D videos
play flat.
- **Most 3D (stereo) videos play flat.** On desktop, YouTube sends only one eye's picture for
most 3D videos (and caps some VR180 videos at 4K), while the Meta Quest app gets both eyes.
StreamXR shows what YouTube's own player plays and won't bypass it: [why](docs/decisions/0002-site-player-only.md).
The 3D videos that do arrive with both eyes side by side play in 3D (Format menu).
- **Shorts are very tall** on the cinema screen: they keep their vertical shape at the
screen's full width. Honestly, I left this in because I think it's funny, but I'll fix it
eventually lol 😂
@@ -364,18 +396,28 @@ which is often all that's needed. 📋
No dates, just what I'll work on after release, in this order:
1. 🐛 **Immediate bug fixes:** whatever the first wave of users runs into.
2. 🏪 **Chrome Web Store release:** install StreamXR in one click and get updates
automatically, no more downloading new versions by hand. Still free, of course.
3. 💎 **YouTube Premium features:** Premium quality levels, Add to queue and more.
4. 🧭 **Explore page tabs:** Live, Gaming, Music, Podcasts and the rest of the "Coming Soon"
2. 📋 **Add to queue:** line up videos to watch next from any panel, without leaving VR.
3. 🧭 **Explore page tabs:** Live, Gaming, Music, Podcasts and the rest of the "Coming Soon"
tabs.
5. 💬 **Live Chat:** See the chat of a Live Stream while watching
6. 💬 **Commenting and Live Chat in-headset:** write comments, replies, and type in a live chat with the VR keyboard.
7. 🟣 **Twitch.tv support:** the first site after YouTube.
8. 🔐 **Groundwork for YouTube Movies, Netflix, HBO Max, Disney+ and other protected videos:** the
infrastructure to play them in VR, ready for the day they allow it
([why they don't yet](#-what-about-netflix-max-and-the-others)).
9. ✨ **Whatever else I can think of** (and whatever you ask for!).
4. ✅ **Live Chat:** done (v0.88.1): read, react and type in a live stream's chat.
5. ✅ **YouTube Premium:** done (v0.93.3): 1080p Premium and Jump ahead, marked with the Diamond.
6. 💬 **Commenting in-headset:** write comments and replies with the VR keyboard (typing in a live
chat is already in).
7. 🏪 **Chrome Web Store release, StreamXR 1.0:** once the YouTube features are all in, install
StreamXR in one click and get updates automatically, no more downloading new versions by
hand. Still free, of course.
8. 🟣 **Twitch.tv support:** the first site after YouTube.
9. 🪼 **Jellyfin** and 🟠 **Plex support:** your own media server's movies and shows in VR, on the
cinema screen. Your own files aren't copy-protected, so they will be able to play.
10. 🎵 **Music streaming**, starting with **Spotify:** listen while you're in VR, with the player's
controls, album art and queue around you.
11. 🌐 **Barebones mode (experimental):** any video on any website on the cinema screen, with the
basic controls (play, seek, volume, format), for sites without full support. Some sites
won't allow it.
12. 🔐 **Groundwork for YouTube Movies, Netflix, HBO Max, Disney+ and other protected videos:** the
infrastructure to play them in VR, ready for the day they allow it
([why they don't yet](#-what-about-netflix-max-and-the-others)).
13. ✨ **Whatever else I can think of** (and whatever you ask for!).
### ⏳ The "as soon as it's possible" roadmap\*
@@ -433,6 +475,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)).
@@ -481,7 +534,7 @@ A reusable VR **shell** in two parts, the **core** (`src/core/`: the VR interfac
and plugs in through its **provider** (a middle layer), its panels (built on five standard
styles) and its page buttons. No build step and no ES modules: plain scripts loaded in
order by `manifest.json` (one vendored library, Google's Material Color Utilities, in
`third_party/`). There are no 3D formats: on desktop YouTube sends one eye only.
`third_party/`). Most 3D videos reach desktop as one eye only, and StreamXR never bypasses the site's player to get more ([decision 0002](docs/decisions/0002-site-player-only.md)).
| Read | For |
|---|---|
@@ -500,8 +553,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
+70
View File
@@ -0,0 +1,70 @@
# 0002: Play only what the site's own player plays
- **Status:** accepted (the maintainer, 2026-10-17)
- **Prompted by:** a tester's report (u/TehSuperFruitSnack on Reddit) that the Meta Quest app
shows VR180 videos in 3D and up to 8K, while StreamXR shows one eye and 4K at most.
## Context
StreamXR draws the `<video>` element that the site's own player is already playing; it never
fetches a video stream itself. On desktop that has limits (checked in the browser, see
[youtube.md](../youtube.md) "Formats and VR180"):
- **Most 3D VR180 videos reach desktop as one eye only.** YouTube removes the right eye
before the stream reaches the browser (a "LEFT / RIGHT" test video shows LEFT in both
eyes). Some videos reach desktop with both eyes side by side; those StreamXR can show in
3D (a format to pick by hand).
- **Some VR180 videos top out at 4K on desktop** (the player offers 2160s at most), while
others reach 8K.
The Quest app most likely identifies itself to YouTube as a VR client and gets the full
stereo, higher-resolution versions. Matching it would mean **bypassing YouTube's player**:
asking YouTube for the streams as if StreamXR were that app, and playing them in a player of
our own.
## Decision
**StreamXR doesn't bypass the site's player.** It shows what the site's own player plays.
If a site starts sending stereo or higher resolutions to browsers, StreamXR shows them. This
holds for every site, not just YouTube. Suggestions to bypass the player (from users,
contributors or forks) get pointed here.
## Why: the risks of bypassing the player
1. **Rules and distribution.**
- YouTube's Terms of Service don't allow accessing videos other than through YouTube's
own player and interfaces, or getting around its ads.
- The Chrome Web Store rejects extensions that break a site's terms or enable
unauthorized downloading or streaming; that would block the planned store release.
- It invites legal trouble: in 2020 the RIAA had youtube-dl taken down from GitHub,
arguing that working around YouTube's scrambled stream addresses ("rolling cipher") was
circumvention. GitHub reinstated it, but the claim was made.
2. **It breaks constantly.**
- Posing as another app means copying its client name, version and request format,
which YouTube changes often and blocks when they're not genuine.
- Stream addresses are scrambled by code that changes with player updates, and YouTube
increasingly requires proof-of-origin tokens (BotGuard) that are hard to produce
outside its own player.
- Tools like yt-dlp need fixes every few weeks for this; StreamXR would join that race.
3. **Rebuilding the site's player.**
- StreamXR would need its own player feeding the video and audio streams to the browser
(Media Source Extensions) and choosing qualities.
- Captions, audio tracks, quality switching, live streams, chat replays tied to the video
time, ads and Skip ad, and the resume position all come from YouTube's player today;
each would need rebuilding or would break.
4. **Ads and creators.** Playing the streams directly skips YouTube's ads: the thing YouTube
fights hardest, and revenue the creators people are watching depend on.
5. **Users' accounts and privacy.**
- Requests sent with the user's signed-in session while posing as another app are the
kind of traffic YouTube flags; at worst, an account gets warnings, extra verification
or restrictions.
- It needs a host permission for YouTube's video servers, against the project's
"minimal permissions" goal, with a broader warning for users.
## Consequences
- 3D on one-eye VR180 videos, and more than 4K on those YouTube caps on desktop, are
documented as desktop YouTube limits (the README's "Known issues"), not open work.
- What desktop YouTube does send is used fully: 8K where offered, and (planned) a side-by-side 3D
format for videos that arrive with both eyes.
- A fork may decide otherwise; it then takes on the risks above itself.
+72 -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
@@ -223,3 +276,20 @@ even where they look like something to "fix".
- The lasers are smoothed against hand tremor (pointing, clicks and grabbing alike), with
fast moves passing through at once. The user sets how much in Settings → Laser
(Steadiness, Off to Steadiest; the middle is the default, which felt right in testing).
- **Subscriptions (the maintainer, 2026-10-17):** your subscribed channels are a **Channels** tab
in the You panel, right after Subscriptions (round channel pictures, a click opens the
channel in the Channel panel). A channel's **notification bell** (All / Personalized / None)
is a bell button beside Subscribe on the Channel panel, opening a small floating menu like
the controls panel's menus. The Subscriptions feed was to show **community posts** (desktop's
feed has none: 0.90.2), but not its
shelves (the Shorts row, "Most relevant"), as on Home.
- **Premium (the maintainer, 2026-10-17):** from the audit in `features/video-page.md`: 1080p
Premium in the Quality menu and Jump ahead, with the Diamond; queueing isn't Premium on
desktop and is its own item.
- **Jump ahead sits at the seek bar's end** (the maintainer, 2026-10-17, 0.92.1), where Skip ad
shows during ads, not in the button row: the Diamond, "Jump ahead", fast-forward.
- **3D side by side on the cinema screen** (the maintainer, 2026-10-17, 0.93.0): its own
format with Material Symbols' `3d_2` icon (0.93.3); 180° 3D side by side keeps `3d_rotation`.
- **The Format button says "Format"** (the maintainer, 2026-10-17, 0.93.2), whatever's chosen;
its icon shows the format, and the Format menu shows every format's icon and name. The
cinema screen's icon is `2d_2`, 3D Cinema's `3d_2` (0.93.3).
+16 -3
View File
@@ -40,15 +40,19 @@ the same shape.
- VR idea: a Subscribe button on the Channel panel (`channel-page.md`) and on
Up Next cards. Also lets the controls panel's Subscribe button unsubscribe, which it
can't do today (YouTube's confirm menu blocks it).
2. [ ] **Shelves and posts in the feed** (small to medium)
2. [ ] **Posts in the feed**: deferred (0.90.2, see "Deferred"). The shelves (a Shorts row,
"Most relevant") are deferred too, as on Home.
- Code: `p.shelves` (Shorts row, "Most relevant"), `p.posts`
- VR idea: as on Home. Posts need the post cards from the channel Posts tab.
3. [ ] **Manage list: your subscribed channels** (medium)
3. [x] ~~**Manage list: your subscribed channels**~~ DONE (v0.90.0, works in the headset; sort pills 0.92.2: You →
Channels, round pictures, a click opens the Channel panel)
- Code: `channels()` -> [{ id, title, handle, subscribers, thumb, subscribed,
notifications }]
- VR idea: a list of channel rows (avatar, name, counts); clicking one opens its Channel
panel. Needs a scrolling list rather than the card grid.
4. [ ] **Notification bell per channel** (medium)
4. [x] ~~**Notification bell per channel**~~ DONE (v0.90.0, works in the headset: the bell button
beside Subscribed on the Channel panel, a floating menu of YouTube's choices; the Channels
list shows each setting)
- Code: `setNotifications(option)` with one of a channel's `notifications.options`
(All / Personalized / None); `[]` means YouTube offers no bell for that channel.
- VR idea: a bell button on each Manage row and on the Channel panel, opening a small
@@ -78,3 +82,12 @@ needed for now (Subscribe from the controls panel and the Channel panel works in
3. **Watch later / Liked videos / your playlists**: their browse ids are in
`youtube-home.js` (`feed('watchLater')`, `feed('liked')`) but untested signed in;
saving a video to a playlist needs another request.
## Deferred (the maintainer, 2026-10-17)
- **Shelves in the Subscriptions feed** (a Shorts row, YouTube's "Most relevant"): for the same
reason as Home's shelves (`home-page.md`, "Deferred"): Shorts fit VR poorly. `p.shelves` is
still read.
- **Posts in the Subscriptions feed** (0.90.2): desktop's feed doesn't mix community posts in
(a probe signed in found no post data in `FEsubscriptions`), so the Posts pill built in
0.90.0 was always empty and was taken out. Posts stay on each channel's Posts tab.
+56 -14
View File
@@ -51,10 +51,25 @@ published; what matters from them is in this file and docs/youtube.md).
`provider/cards.js`); check Browse and Search results show movies the same way.
- [ ] **Premium features** (the maintainer, after 0.78.4): YouTube Premium's extras in VR, marked with
Material Symbols' **Diamond** icon: **purple** when you don't have Premium (an upsell, like
YouTube's own), **YouTube Red** when you do. To include: the Premium quality levels
("1080p Premium" in the Quality menu: check they show and work), **Add to queue**, and
Premium's other features (to list from the watch page: e.g. Jump ahead, downloads where
they make sense in VR). Find out first how the page says whether you have Premium.
YouTube's own), **YouTube Red** when you do. Find out first how the page says whether you
have Premium (a probe signed in with Premium).
- **Audit** (2026-10-17, from YouTube's help article "YouTube Premium benefits", answer
6308116, computer and Android versions):
| Benefit | On desktop web? | In StreamXR |
|---|---|---|
| **1080p Premium** (enhanced bitrate, 1080p uploads only) | yes, an extra Quality entry | **Done (0.91.0, works in the headset):** its own Quality row with the Diamond (red with Premium; purple and greyed out without) |
| **Jump ahead** (to the point most viewers skip to) | yes (double-click, Ctrl + →) | **Done (0.92.3, works in the headset):** a button at the seek bar's end (Skip ad's spot) with the Diamond, shown while YouTube's own pill is up; clicking it clicks that pill |
| Ad-free videos | yes | Already: no ads means nothing to skip |
| Continue watching across devices | yes (account) | Already: YouTube's player resumes |
| Queue | for everyone on desktop (Premium only on phones) | Not Premium here: its own item (Add to queue, below) |
| Downloads (offline) | desktop has a Download button | Deferred: watching in VR needs the PC online anyway |
| Background play, picture-in-picture, Smart downloads, On-the-go mode, Auto speed | phones only | Out |
| Higher-quality audio (256 kbps music) | apps and TVs only | Out |
| YouTube Music app, Premium badges, Co-Watch with Meet, Afterparties, smart devices | other apps | Out |
| Early access to new features (experiments) | varies | Out: they come and go |
- [ ] **Add to queue** (desktop: for everyone, not Premium): queue a video from a card and
play the queue in order. Find out first how the page's queue can be driven.
## Medium: new panels with scrolling or long text
@@ -84,18 +99,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 +137,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
+14 -1
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
@@ -33,7 +46,7 @@ permissions, Firefox) are in [ARCHITECTURE.md](../ARCHITECTURE.md#known-limits).
- The Channel header's subscribed state can be wrong for a channel whose Home tab features
other channels; it's only used when the watch page doesn't say.
- Text matching assumes **English** YouTube: some readers recognise YouTube's own words
(the "All" chip, "Joined", an ad's "1 of 2"). This is about what YouTube writes;
(the "All" chip, "Joined", an ad's "1 of 2", the Jump ahead pill). This is about what YouTube writes;
StreamXR's own words follow the interface language (Settings → Language).
- Replies to replies (YouTube's newer nested comment threads) aren't shown; one level is.
+68 -15
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.93.3.
## Top priority: before the first release
@@ -49,16 +49,34 @@ until another headset or two has been tried and the unverified items are cleared
## The public roadmap (README, after release, in this order)
1. Immediate bug fixes (what the first users run into).
2. Chrome Web Store release (one-click install, automatic updates, still free; needs the
store's privacy questions answered: the README's "Your privacy" section covers them).
3. YouTube Premium features (below: the Diamond icon, Premium qualities, Add to queue ...).
4. Explore page tabs (the "Coming Soon" ones).
2. Add to queue (free on desktop, not Premium; how the page's queue can be driven is to probe
first: `features/video-page.md`).
3. Explore page tabs (the "Coming Soon" ones).
4. Done: Live chat (0.79.0 to 0.88.1), YouTube Premium (1080p Premium 0.91.0, Jump ahead 0.92.3).
5. Commenting in-headset (writing comments and replies with the VR keyboard).
6. Twitch.tv support (the first site after YouTube: a new provider, `src/sites/twitch/`).
7. Groundwork for DRM-protected videos (YouTube Movies, HBO Max and Netflix first, then Disney+, Prime Video): providers and
player ready for when browsers and the services allow protected video in WebXR (e.g. media
layers); the README asks users to tell the services they want desktop XR support.
8. Whatever else comes up.
6. Chrome Web Store release as **1.0** (the maintainer, 2026-10-17: once the YouTube features are
all in; one-click install, automatic updates, still free; needs the store's privacy
questions answered: the README's "Your privacy" section covers them).
7. Twitch.tv support (the first site after YouTube: a new provider, `src/sites/twitch/`; reuses
the live chat panel).
8. Jellyfin and Plex support (new providers, `src/sites/jellyfin/`, `src/sites/plex/`: no DRM on
your own media). Self-hosted servers live at any address, so each needs host access to the
user's server: an optional permission asked when it's turned on (the maintainer agrees on
any permission first: AGENTS.md).
9. Music streaming, Spotify first (its audio is DRM-protected, but sound plays as on the page;
VR shows the player's controls, art and queue, no video to draw). Needs open.spotify.com's
host permission (to agree on first).
10. Barebones mode, **experimental** (the maintainer, 2026-10-17: kept out of 1.0 and after the
full providers, since how well it works depends on each site and first impressions
matter): any `<video>` on any site on the cinema screen with basic controls (play, seek,
volume, format), a generic provider for sites without one. Likely shape: `activeTab` +
`scripting` (it runs only on a page where you click StreamXR's button; nothing added to
the install warning), to agree on first. Unprotected video only (DRM: see 11), and videos
served cross-origin without CORS can't be drawn in WebGL, so many sites will refuse.
11. Groundwork for DRM-protected videos (YouTube Movies, HBO Max and Netflix first, then Disney+, Prime Video): providers and
player ready for when browsers and the services allow protected video in WebXR (e.g. media
layers); the README asks users to tell the services they want desktop XR support.
12. Whatever else comes up.
**As soon as it's possible** (README's second roadmap, ahead of everything above once the support exists):
YouTube Movies (copy-protected: once YouTube allows protected video in desktop XR; showing
@@ -68,19 +86,54 @@ controllers instead of "Oculus Touch"), native Steam Frame support (StreamXR in
Chromium browser, no PC, once that browser supports WebXR), streaming services (HBO Max and
Netflix first) once they allow protected video in desktop XR.
## Next: from u/TehSuperFruitSnack's Reddit feedback (after 0.88.1)
1. [ ] **Cubemap seams** (small): thin lines along the cube's edges on 360° cubemap ("MESH")
videos; regular (equirectangular) 360° videos don't have them. Likely cause: the glass look
(on by default) builds mipmaps of the video (`gl-utils.js` `videoMipmaps`), and the
sphere's cubemap lookup (`video-sphere.js` `eacUv`) jumps across the texture where two faces
meet, so the GPU picks the smallest mipmap along those lines. Check first: the seams should
go away with the glass off (Settings → Appearance). Fix: in cubemap mode, sample the video
at full resolution (an explicit level of detail, or gradients from the smooth view direction).
- Test: [SheiKra 8K 360° roller coaster](https://www.youtube.com/watch?v=tR8ZtyhSDYw)
(cubemap, 8K); for comparison, with no seams:
[a regular 360° video](https://www.youtube.com/watch?v=Q7UwGaom0XA).
2. [x] **3D side-by-side videos** (0.89.0, works in the headset; the glass per eye fixed in 0.90.1): some VR180 videos reach desktop with both eyes in
one frame (LEFT | RIGHT), and StreamXR stretches the whole frame over the half sphere. Add a
side-by-side format to the Format menu that gives each eye its half (`video-sphere.js`
`uUvXform`, the per-eye hook); picked by hand, since YouTube's data doesn't mark these
videos (auto-detecting by comparing the frame's halves could come later).
- Test: [VR180 Left/Right](https://www.youtube.com/watch?v=ccU0imlNp5Q) (side by side:
LEFT in the left eye, RIGHT in the right). For comparison, one eye only, which stays flat:
[Left Right Test in VR 180](https://youtu.be/kle719aQvsc?t=8).
- Not planned: 3D for videos that reach desktop as one eye, and more than 4K where desktop
YouTube caps it (e.g. [this VR180 tour](https://www.youtube.com/watch?v=KJ-_LUrGhYE)):
desktop YouTube limits ([decisions/0002](decisions/0002-site-player-only.md)).
3. [x] **3D side by side on the cinema screen** (0.93.0, works in the headset): the same for
regular (not VR180) 3D videos, each eye its half on the cinema screen.
- Test: [Demo 3D[SBS] (HD : 1080P)](https://www.youtube.com/watch?v=u0hmMt5znX0) (half side
by side: a 16:9 frame, each eye squeezed into half).
## Features to build (easiest first)
- [ ] **Movies in Up Next**: the price / rating in place of views and date, a yellow Movie tag
(`features/video-page.md`).
- [ ] **Premium features**: the Premium quality levels, Add to queue and the rest, marked with the
Diamond icon (purple without Premium, YouTube Red with it) (`features/video-page.md`).
- [x] **Premium features** (audited 2026-10-17, `features/video-page.md`): **1080p Premium** in the
Quality menu and **Jump ahead**, marked with the Diamond (purple without Premium, YouTube Red
with it). 1080p Premium (0.91.0) and Jump ahead (0.92.3) work in the headset. **Add to queue** is its own item
(not Premium on desktop).
- [ ] **Explore's "Coming Soon" tabs**: Live, Gaming, Music, Hype, Courses, Podcasts,
Memberships (readers exist); Shorts, Movies & TV, Learning need more first
(`features/home-page.md`).
- [ ] **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`).
- [x] **Subscriptions** (0.90.0 to 0.92.2, work in the headset): the Channels tab with its sort pills, the bell on the Channel
panel, posts in the feed; the feed's shelves deferred (`features/subscriptions-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`)
+156 -4
View File
@@ -19,6 +19,9 @@ backlogs in [features/](features/)):
- `youtube-player.js` (`ns.youtube`): everything on the `#movie_player` API.
- `youtube-page.js` (`ns.youtubePage`): Up Next (merged data + DOM scraping, load more,
playlists), the controls panel's details, Subscribe / Like / Dislike, in-page navigation.
In-page navigation is a `yt-navigate` event with a watch endpoint, sent from
`ytd-page-manager`: sent from `ytd-app` itself, YouTube ignored it on a channel page (it
worked on the watch page and Home), checked 2026-10-17.
- `youtube-content.js` (`ns.youtubeContent`): metadata, description, owner, chapters,
heatmap, end screen, cards, playlist, comments / replies, transcript, search.
- `youtube-channel.js` (`ns.youtubeChannel`): any channel by id (`currentChannelId()` for the
@@ -36,7 +39,18 @@ backlogs in [features/](features/)):
Manage list `subscribed` is always true; one channel had only a stub `subscriptionButton`
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).
account (the documented ones seem app-only). **No community posts** in the desktop feed
(signed in, 2026-10-17: no post data in `FEsubscriptions` at all). The Manage page
(`FEchannels`, seen in the signed-in page's `ytInitialData`, 2026-10-17): the list is split
into **titled shelves** (`shelfRenderer` inside the item section: "Purchased" for channels
you're a member of, then "Subscribed"), and its **sort dropdown** (Most relevant, New
activity, A-Z) is a set of `listItemViewModel`s with `entityKey`
`channel_list_page_sort_chip_entity:<name>`, each tap updating a `chipEntity` (the
dropdown's label) and sending a browse `continuationCommand` that reloads the list in that
order (`sortsOf`, `channelsOn`).
- `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.
@@ -93,10 +107,35 @@ The formats are in `src/streamxr/shared/formats.js`; YouTube's detection is the
from `getAvailableQualityData()` (`qualityLabel`, e.g. "1080p60", or "2160s60" for a 360° /
VR180 video: "s" for spherical) where the player has it
(`youtube-player.js` `quality().labels`); with Premium it also lists "1080p Premium",
skipped in favour of the plain entry. Unverified in the headset (see known-issues.md).
kept apart as its own level (0.91.0). Seen signed in with Premium (2026-10-17): the entry
has `formatId` "721", `quality` "hd1080", `paygatedQualityDetails.paygatedIndicatorText`
"Enhanced bitrate", `isPlayable` true, beside the plain 1080p (`formatId` "399"); it's
chosen with `setPlaybackQualityRange(q, q, formatId)` and `getPlaybackQualityLabel()` names
the one playing. **Whether you have Premium:** the top bar's logo,
`ytInitialData.topbar.desktopTopbarRenderer.logo.topbarLogoRenderer.iconImage.iconType`
is `YOUTUBE_PREMIUM_LOGO` (`youtube-player.js` `hasPremium`). **Jump ahead:** the page has
no data for it, and the player's `prefetchJumpAhead()` returns undefined and moves nothing.
YouTube shows a **Jump ahead pill** in the player's "timely actions" overlay at the moments
it applies (`.ytp-timely-actions-overlay ytw-timely-action-view-model button`, its text
"Jump ahead"), and a script's `click()` on it jumps (17 s -> 124 s, 2026-10-17), so
`youtube-player.js` `jumpAhead()` clicks it and `jumpAheadOffered()` tells whether it's up.
A script-sent Ctrl + → keydown on `#movie_player` jumped from the console but did nothing
from VR.
- Desktop YouTube sends **one eye only**, even for 3D videos, so there are no 3D formats.
- Formats: `flat` (cinema screen), `360mono`, `360eac`, `180mono`.
- Desktop YouTube sends **one eye only** for most 3D videos (checked 2026-10-17 with frames
from the page's `<video>`): the "Left Right Test in VR 180" (`kle719aQvsc`, 1920×2160) and
a 3D VR180 tour (`KJ-_LUrGhYE`, 3840×2160, 2160s at most on desktop) hold the left eye
only. **Some arrive side by side** (both eyes in one frame): "VR180 Left/Right"
(`ccU0imlNp5Q`, 3840×2160, LEFT | RIGHT). Nothing in the player response tells them apart
(all `MESH`, `vrConfig.partialSpherical`, no stereo field). The Quest app gets both eyes
and higher resolutions, most likely as a VR client; StreamXR doesn't bypass the page's
player to get them ([decisions/0002](decisions/0002-site-player-only.md)).
- 8K reaches desktop where YouTube offers it, e.g. an 8K 360° video (`tR8ZtyhSDYw`):
`4320s`, 7680×3840, AV1 (itag 571).
- Formats: `flat` (cinema screen), `flatsbs` (the cinema screen in 3D side by side: 0.93.0;
e.g. "Demo 3D[SBS]", `u0hmMt5znX0`, a RECTANGULAR 16:9 frame, each eye squeezed into half),
`360mono`, `360eac`, `180mono`, `180sbs` (3D side by side,
picked by hand: 0.89.0).
- "MESH" 360° videos use an **equi-angular cubemap**, 3×2: top row left | front | right,
bottom row down | back | up; down and up rotated 270° clockwise, back 90° (the same as
ffmpeg's `eac`). Verified against a real frame.
@@ -153,6 +192,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.93.3",
"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) {
+21 -11
View File
@@ -38,10 +38,14 @@
// drawn at the tint, so alpha / tint is 1 anywhere on the panel, less on its
// antialiased edge, 0 outside it);
// - behind(): the ray from the eye (uEye) through this point, into the backdrop:
// uKind 1 'sphere' (equirectangular, uSpan radians of longitude, turned by uYaw),
// uKind 1 'sphere' (equirectangular, uSpan radians of longitude, turned by uYaw;
// its across coordinate then scaled and shifted by uUvX: (1, 0) for the whole
// frame, (0.5, 0) / (0.5, 0.5) for the left / right eye's half of a side-by-side 3D
// frame, set per eye),
// uKind 2 'eac' (YouTube's cubemap, the same layout as video-sphere.js),
// uKind 3 'screen' (the cinema screen: centre uScrC, axes uScrR / uScrU / uScrN
// (toward you), size uScrSize, bent by uBend radians around a vertical circle);
// (toward you), size uScrSize, bent by uBend radians around a vertical circle;
// sampled at this eye's part of the frame across, uUvX, as above);
// its texture coordinates, or "outside" (the dark void colour, uVoid);
// - the blur: the backdrop texture with a mipmap bias (texture2D's third argument, so
// a much smaller mipmap is read), at the centre and four points around it (uBlur
@@ -59,7 +63,7 @@
uniform sampler2D uTex, uBack;
uniform float uGlass, uTint, uKind, uSpan, uYaw, uBend, uBlur, uAlpha;
uniform vec3 uEye, uScrC, uScrR, uScrU, uScrN, uVoid;
uniform vec2 uScrSize, uTexel;
uniform vec2 uScrSize, uTexel, uUvX;
uniform vec3 uGlow; // the cinema screen's glow: strength (0: none), reach (m), corner radius (m)
varying vec2 vUv; varying vec3 vWorld;
const float PI = 3.14159265;
@@ -93,7 +97,7 @@
vec3 l = vec3(c * d.x - s * d.z, d.y, s * d.x + c * d.z);
if (uKind > 1.5) return vec3(eacUv(l), 1.0);
float lon = atan(l.x, -l.z), pol = acos(clamp(l.y, -1.0, 1.0));
return vec3(lon / uSpan + 0.5, pol / PI, step(abs(lon), uSpan * 0.5));
return vec3((lon / uSpan + 0.5) * uUvX.x + uUvX.y, pol / PI, step(abs(lon), uSpan * 0.5));
}
// The cinema screen, in its own axes.
vec3 e = eye - uScrC;
@@ -132,13 +136,16 @@
vec3 back = uVoid;
if (uKind > 0.5) {
vec3 b = behind(uEye, normalize(vWorld - uEye));
// On the cinema screen, sample this eye's part of the frame across (uUvX; the picture's
// own coordinates, b.xy, stay for the glow's shape).
vec2 sb = uKind > 2.5 ? vec2(clamp(b.x, 0.0, 1.0) * uUvX.x + uUvX.y, b.y) : b.xy;
if (b.z > 0.75) {
const float BIAS = 5.0;
vec3 s = texture2D(uBack, b.xy, BIAS).rgb * 0.4;
s += texture2D(uBack, b.xy + vec2(uBlur, uBlur), BIAS).rgb * 0.15;
s += texture2D(uBack, b.xy + vec2(-uBlur, uBlur), BIAS).rgb * 0.15;
s += texture2D(uBack, b.xy + vec2(uBlur, -uBlur), BIAS).rgb * 0.15;
s += texture2D(uBack, b.xy + vec2(-uBlur, -uBlur), BIAS).rgb * 0.15;
vec3 s = texture2D(uBack, sb, BIAS).rgb * 0.4;
s += texture2D(uBack, sb + vec2(uBlur, uBlur), BIAS).rgb * 0.15;
s += texture2D(uBack, sb + vec2(-uBlur, uBlur), BIAS).rgb * 0.15;
s += texture2D(uBack, sb + vec2(uBlur, -uBlur), BIAS).rgb * 0.15;
s += texture2D(uBack, sb + vec2(-uBlur, -uBlur), BIAS).rgb * 0.15;
back = s;
} else if (b.z > 0.25 && uGlow.x > 0.0) {
// Past the screen's edge: its glow (cinema-screen.js GLOW_FS) added to the dark, the
@@ -149,7 +156,7 @@
vec2 q = abs(p) - uScrSize * 0.5 + uGlow.z;
float dist = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - uGlow.z;
float k = clamp(dist / uGlow.y, 0.0, 1.0), a = uGlow.x * (1.0 - k) * (1.0 - k);
if (a > 0.002) back += texture2D(uBack, b.xy, 6.0).rgb * a;
if (a > 0.002) back += texture2D(uBack, sb, 6.0).rgb * a;
}
}
float grain = fract(sin(dot(floor(vUv / uTexel), vec2(12.9898, 78.233))) * 43758.5453) - 0.5;
@@ -179,7 +186,7 @@
const prog = G.createProgram(gl, VS, FS, 'vr panel');
const U = G.uniforms(gl, prog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', tex: 'uTex', back: 'uBack',
alpha: 'uAlpha', glass: 'uGlass', tint: 'uTint', kind: 'uKind', span: 'uSpan', yaw: 'uYaw', bend: 'uBend', blur: 'uBlur', eye: 'uEye',
scrC: 'uScrC', scrR: 'uScrR', scrU: 'uScrU', scrN: 'uScrN', voidC: 'uVoid', scrSize: 'uScrSize', texel: 'uTexel', glow: 'uGlow' });
scrC: 'uScrC', scrR: 'uScrR', scrU: 'uScrU', scrN: 'uScrN', voidC: 'uVoid', scrSize: 'uScrSize', texel: 'uTexel', glow: 'uGlow', uvX: 'uUvX' });
const vao = vaos.create(); vaos.bind(vao);
// Two triangles making the square (6 corners, x/y pairs).
const buf = G.floatAttribute(gl, prog, 'aCorner', new Float32Array([-0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5]), 2);
@@ -220,6 +227,9 @@
gl.uniform1f(U.blur, BLUR[backdrop.kind] || 0.01);
gl.uniform1f(U.span, backdrop.span || Math.PI * 2);
gl.uniform1f(U.yaw, backdrop.yaw || 0);
// (uvX: one [scale, offset] for both eyes, or { left, right }: each eye its own part)
const ux = backdrop.uvX, eyeX = !ux ? [1, 0] : Array.isArray(ux) ? ux : view.eye === 'right' ? ux.right : ux.left;
gl.uniform2fv(U.uvX, eyeX);
if (backdrop.kind === 'screen') {
gl.uniform3fv(U.scrC, backdrop.centre); gl.uniform3fv(U.scrR, backdrop.right);
gl.uniform3fv(U.scrU, backdrop.up); gl.uniform3fv(U.scrN, backdrop.normal);
+52 -15
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:
@@ -39,7 +40,11 @@
// "Skip on desktop" without the Skip Helper extension),
// extras: { left: [...], right: [...] } (the site's own buttons, at the inner ends
// of the outer parts of the button row: { id, icon, label (under the
// icon), on (icon filled), disabled }; ids 'x:<id>'),
// icon), on (icon filled), disabled, icons (optional: one or two icons, e.g.
// { name, color } ones, stacked in place of icon + label, like Quality's) },
// and seek: null | { id, label, icon, mark (an icon spec on its left, e.g.
// a coloured one), disabled } (one button at the seek bar's right end, where
// Skip ad goes during ads; the bar shortens for it); ids 'x:<id>'),
// menuOpen: null | 'quality' | 'speed' | 'format' | 'volume' | 'x:<id>' (that button
// is shown selected: its menu floats in front of the panel, popups-ui.js) }
// hover/pressed: a control id or null. Fixed ids: seek, exit, quality, speed, back10,
@@ -70,9 +75,11 @@
};
// The button row: one M3E button group, in three parts ([Exit, Quality, Speed, the site's
// left extras] [-10 s, play / pause, +10 s] [the site's right extras, Volume, Format,
// Recenter]), centred (room for one extra a side). Buttons are BTN.w x
// BTN.h, play / pause is bigger; GAP between buttons, SEP between the parts.
// Recenter]), centred (room for one extra a side at full width; with more, the buttons
// narrow to stay inside ROW_MARGIN). Buttons are BTN.w x BTN.h, play / pause is bigger;
// GAP between buttons, SEP between the parts.
const BTN = { w: 88, h: 88, y: ROW_Y + 2 }, PLAY = { w: 132, h: 104, y: ROW_Y - 6 }, GAP = 8, SEP = 20;
const ROW_MARGIN = 48; // the button row keeps this far from the panel's sides
// In a playlist: Previous / Next video buttons at the two ends of the seek row, and the
// seek bar shortened between them.
const PLAYLIST = {
@@ -90,7 +97,7 @@
const tr = (key, vars) => ns.i18n.t(key, vars); // words in the interface language (i18n.js)
// Which icon a format gets on the Format button.
const FORMAT_ICONS = { flat: 'theaters', '360mono': '360', '360eac': 'view_in_ar', '180mono': 'panorama_photosphere' };
const FORMAT_ICONS = { flat: '2d_2', flatsbs: '3d_2', '360mono': '360', '360eac': 'view_in_ar', '180mono': 'panorama_photosphere', '180sbs': '3d_rotation' };
// The speaker for a volume (0-100) / muted.
const volumeIcon = v => (v.muted ? 'volume_off' : v.level <= 0 ? 'volume_mute' : v.level < 50 ? 'volume_down' : 'volume_up');
@@ -228,8 +235,8 @@
// An icon over a small label (the button row's buttons), or the icon alone, centred.
// One icon big, or two stacked (the first smaller and higher, e.g. 4K over 60 fps).
function iconStack(icons) {
if (icons.length < 2) { S.draw(g, icons[0], 0, 0, 48, false); return; }
S.draw(g, icons[0], 0, -14, 38, false); S.draw(g, icons[1], 0, 20, 30, false);
if (icons.length < 2) { S.drawIcon(g, icons[0], 0, 0, 48, false); return; }
S.drawIcon(g, icons[0], 0, -14, 38, false); S.drawIcon(g, icons[1], 0, 20, 30, false);
}
function iconLabel(icon, text, filled = false) {
if (text) { S.draw(g, icon, 0, -13, 36, filled); label(text, 'labelMedium', 23); }
@@ -317,9 +324,6 @@
// The button row (see BTN): each shows its icon and, under it, its current value.
function drawTransport(state) {
const v = state.volume || { level: 100, muted: false };
// The format's short name, first line only (a two-line name would be too small; the
// icon tells the formats apart).
const fmtLabel = ((state.format && state.format.label) || '—').split('\n')[0];
// The site's own buttons (state.extras): [id, icon, label, extra].
const extras = side => ((state.extras && state.extras[side]) || []).map(x => [`x:${x.id}`, x.icon, x.label || '', x]);
const items = [
@@ -329,10 +333,15 @@
['back10', 'replay_10', ''], ['play', state.paused ? 'play_arrow' : 'pause', ''], ['fwd10', 'forward_10', ''], null,
...extras('right'),
['volume', volumeIcon(v), v.muted ? tr('panel.muted') : `${Math.round(v.level)}%`],
['format', FORMAT_ICONS[state.format && state.format.id] || 'theaters', fmtLabel],
['format', formatIcon(state.format && state.format.id), tr('panel.format')], // (the icon tells the formats apart)
['recenter', 'recenter', tr('panel.recenter')],
];
const width = it => (it[0] === 'play' ? PLAY.w : BTN.w);
// Buttons narrow (from BTN.w) when a site's extra buttons would push the row past the
// panel's side margins; heights stay.
const n = items.filter(it => it && it[0] !== 'play').length, seps = items.filter(it => !it).length;
const gaps = (items.length - seps - 1) * GAP + seps * (SEP - GAP);
const bw = Math.min(BTN.w, Math.floor((W - ROW_MARGIN * 2 - PLAY.w - gaps) / Math.max(1, n)));
const width = it => (it[0] === 'play' ? PLAY.w : bw);
const total = items.reduce((s, it) => s + (it ? width(it) + GAP : SEP - GAP), -GAP);
let x = (W - total) / 2;
for (const it of items) {
@@ -344,8 +353,9 @@
} else {
// the button whose menu is open shows selected (its menu floats in front: popups-ui.js);
// an extra that's `on` (e.g. captions showing) has its icon filled
const qi = id === 'quality' && state.quality && state.quality.icons; // the quality as icons (its full name is in its menu)
button({ x, y: BTN.y, w: BTN.w, h: BTN.h }, id, state, () => (qi && qi.length ? iconStack(qi) : iconLabel(icon, text, !!(extra && extra.on))),
// the quality as icons (its full name is in its menu), or a site button's own icons
const qi = id === 'quality' ? state.quality && state.quality.icons : extra && extra.icons;
button({ x, y: BTN.y, w: bw, h: BTN.h }, id, state, () => (qi && qi.length ? iconStack(qi) : iconLabel(icon, text, !!(extra && extra.on))),
{ kind: state.menuOpen === id ? 'selected' : 'tonal', disabled: !!(extra && extra.disabled) });
}
x += width(it) + GAP;
@@ -407,6 +417,16 @@
regions = [];
const queue = state.nowPlaying && state.nowPlaying.queue;
seekRect = queue && !state.ad ? PLAYLIST.seek : FIXED.seek;
// The site's seek-row button (state.extras.seek, e.g. Jump ahead), where Skip ad goes
// during ads: at the seek bar's right end (before a playlist's next button), the bar
// shortened to make room.
const sa = !state.ad && state.extras && state.extras.seek;
let saRect = null;
if (sa) {
const right = queue ? PLAYLIST.next.x - 16 : seekRect.x + seekRect.w;
saRect = { x: right - FIXED.skipad.w, y: FIXED.skipad.y, w: FIXED.skipad.w, h: FIXED.skipad.h };
seekRect = { ...seekRect, w: saRect.x - 24 - seekRect.x };
}
g.clearRect(0, 0, W, H);
g.fillStyle = bodyFill();
roundRect(g, 0, 0, W, BODY_H, T.shape.extraLarge); g.fill();
@@ -460,6 +480,7 @@
else {
drawSeekBar(state, shown);
if (queue) drawPlaylistButtons(queue, state);
if (sa) drawSeekAction(sa, saRect, state);
}
drawTransport(state); // the button row
@@ -502,6 +523,19 @@
}
}
// The site's seek-row button (id 'x:<id>', like its other extras), shaped like Skip ad:
// its mark (an icon, e.g. the Diamond in its own colour) on the left, the label, its icon
// on the right. Greyed out when disabled.
function drawSeekAction(x, r, state) {
button(r, `x:${x.id}`, state, () => {
const left = x.mark ? 36 : 0;
if (x.mark) S.drawIcon(g, x.mark, -r.w / 2 + 30, 0, 26, true);
const t = ns.vrKit.fitFont(g, x.label || '', r.w - 84 - left, 24, '500 ');
g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(t, -20 + left / 2, 1);
if (x.icon) S.draw(g, x.icon, r.w / 2 - 34, 0, 30, true);
}, { disabled: !!x.disabled });
}
// A live stream, in place of the time (top right): a "● LIVE" chip in the brand colour at
// the live moment. Behind it (rewound, paused, or while scrubbing) the chip turns tonal,
// with how far behind in front ("−0:45"), and clicking it (id 'live') jumps back to the
@@ -623,5 +657,8 @@
titleMoving: () => titleMoving, animating: motion.moving };
}
ns.vrPanelUI = { createPanelUI, formatTime: fmt, speedName };
// The Format button's icon for a format id (also each row's icon in the Format menu: vr-panel.js).
const formatIcon = id => FORMAT_ICONS[id] || '2d_2';
ns.vrPanelUI = { createPanelUI, formatTime: fmt, speedName, formatIcon };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+42 -17
View File
@@ -10,7 +10,10 @@
// 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, or
// { name, color } for one in its own colour (symbols.js drawIcon), 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 +25,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 +41,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 +75,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;
@@ -87,7 +97,11 @@
list = { x: card.x, y: card.y + PAD + noteH, w: listW, h: shownRows * rowH };
maxScroll = m.scroll ? Math.max(0, items.length * rowH - list.h) : 0;
scroll = Math.min(maxScroll, Math.max(0, scroll));
g.save(); g.beginPath(); g.rect(list.x, list.y, list.w, list.h); g.clip();
// Clipped to the rows, plus the card's padding when nothing scrolls (so the hover ring
// round the first and last rows, drawn just outside them, isn't cut).
const bleed = maxScroll ? 0 : PAD;
g.save(); g.beginPath(); g.rect(list.x, list.y - bleed, list.w, list.h + bleed * 2); g.clip();
const rings = []; // [row, radius, amount] to ring after the rows
items.forEach((it, i) => {
const r = { x: card.x + PAD, y: list.y + i * rowH - scroll, w: listW - PAD * 2 - (maxScroll ? 12 : 0), h: rowH };
if (r.y + r.h < list.y || r.y > list.y + list.h) return; // scrolled out of view
@@ -101,15 +115,18 @@
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((icon, k) => S.drawIcon(g, icon, 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);
if (hv > 0.01) rings.push([r, rad, hv]);
});
// The pointing rings last, so a neighbouring row (e.g. the ticked one's fill) can't cover one.
for (const [r, rad, hv] of rings) hoverRing(g, r.x, r.y, r.w, r.h, rad, hv);
g.restore();
if (maxScroll) { // the scroll bar: where the view is in the list
const th = Math.max(36, list.h * list.h / (list.h + maxScroll)), ty = list.y + (list.h - th) * scroll / maxScroll;
@@ -163,7 +180,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 +191,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)
+14 -6
View File
@@ -12,7 +12,9 @@
// current (outlined in blue: the item playing now),
// progress (0-1: how much of it you've watched, a bar along the thumbnail's bottom in
// the brand colour (the site's theme); 0 or missing: no bar),
// fit ('contain': the whole picture, letterboxed, e.g. a product; default: cropped to fill),
// fit ('contain': the whole picture, letterboxed, e.g. a product; 'round': a round picture
// in the middle, e.g. a person or a channel (a letter until it loads); default:
// cropped to fill),
// inert (just to look at: not clickable, no hover highlight) }
// Badges: a site registers its own (registerBadge); drawBadge(name) draws one if registered.
(function (ns) {
@@ -90,13 +92,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 +112,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;
}
@@ -166,7 +170,11 @@
// Thumbnail (a grey box with a play triangle until it loads)
g.save(); roundRect(g, x, y, W_, TH, T.shape.medium); g.clip();
const img = image(card.thumb);
if (img && card.fit === 'contain') { g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH); drawImageContain(img, x, y, W_, TH); }
if (card.fit === 'round') { // a round picture (a person) in the middle of the box
const s = Math.round(TH * 0.8);
g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH);
avatar(card.thumb, card.title, x + (W_ - s) / 2, y + (TH - s) / 2, s);
} else if (img && card.fit === 'contain') { g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH); drawImageContain(img, x, y, W_, TH); }
else if (img) drawImageCover(img, x, y, W_, TH);
else {
g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH);
+12 -3
View File
@@ -13,6 +13,8 @@
// instead of the title while header is null),
// action: { id, label, look: 'primary' | 'secondary' | 'danger' | 'busy' } | null
// (one button on the right; 'busy' isn't clickable),
// iconAction: { id, icon, look: 'primary' | 'secondary' | 'busy' } | null (a round
// icon button just left of the action button, e.g. one that opens a menu),
// tabs: [{ id, label }], tab, pills: [{ id, label }], pill,
// search: bool (a search button at the tab row's end, 'search'),
// field: null | a text field (grid.js: { id, text, placeholder, focused, button,
@@ -41,7 +43,9 @@
// 'clear:<id>' / 'submit:<id>' (searching), the thread's own (thread.js), 'text' (anywhere
// else on a scrolling body: a trigger drag can start there).
// Scrolling (a body): scrollAt(u, v), maxScroll(), shownScroll(), pxPerV.
// Also: hitTest(u, v) -> { id } | null, has(id), imagesReady(), perPage, canvas, aspect.
// Also: hitTest(u, v) -> { id } | null, has(id), rectOf(id) -> { x, y, w, h } (px) | null
// (a control's place in the last draw, for a menu opening from it), imagesReady(),
// perPage, canvas, aspect.
(function (ns) {
'use strict';
@@ -60,6 +64,7 @@
// the same everywhere on this page).
function create(onImageLoaded, W = 1280, H = 960, overrides = {}) {
const ACTION = { x: W - PAD - 240, y: 132, w: 240, h: 60 };
const ICON_ACTION = { x: ACTION.x - 60 - 16, y: ACTION.y, w: 60, h: 60 };
// A narrow panel puts the pills on a row of their own under the tabs, so the tabs keep
// their names; everything below moves down with it.
const PILLS_Y = W < NARROW ? TAB_Y + TAB_H + 10 : TAB_Y;
@@ -98,7 +103,7 @@
// Name (+ badge) and the stats line beside the picture.
function drawTitle(m) {
const h = m.header;
const maxW = ACTION.x - NAME_X - 24;
const maxW = (m.iconAction ? ICON_ACTION.x : ACTION.x) - NAME_X - 24;
g.textAlign = 'left'; g.textBaseline = 'middle';
g.fillStyle = P.onSurface; g.font = T.font('titleLargeEmphasized', 36);
const name = wrap(g, h ? h.title : m.loadingTitle, maxW - (h && h.badge ? 34 : 0), 1)[0] || '';
@@ -117,6 +122,9 @@
if (!a) return;
ns.vrKit.button(g, regions, `action:${a.id}`, ACTION.x, ACTION.y, ACTION.w, ACTION.h,
{ label: a.label, kind: KIND[a.look] || 'filled', enabled: a.look !== 'busy' }, ui);
const i = m.iconAction; // the round icon button left of it
if (i) ns.vrKit.button(g, regions, `action:${i.id}`, ICON_ACTION.x, ICON_ACTION.y, ICON_ACTION.w, ICON_ACTION.h,
{ icon: i.icon, kind: KIND[i.look] || 'tonal', enabled: i.look !== 'busy' }, ui);
}
// The featured card (id 'featured'): the thumbnail on the left (with its dot flags drawn
@@ -449,11 +457,12 @@
// (It also tells the sliding rows where the laser is: grid.js strips.)
const hitTest = (u, v) => { grid.point(u * W, v * H); const r = regionAt(regions, u, v, W, H); return r ? { id: r.id } : null; };
const has = id => regions.some(r => r.id === id); // an enabled control from the last draw?
const rectOf = id => { const r = regions.find(x => x.id === id); return r ? { x: r.x, y: r.y, w: r.w, h: r.h } : null; };
// Is (u, v) on the scrolling body of the last draw?
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: grid.PER_PAGE, animating: () => motion.moving() || grid.moving(),
canvas, aspect: H / W, draw, hitTest, has, rectOf, scrollAt, imagesReady: kit.imagesReady, perPage: grid.PER_PAGE, animating: () => motion.moving() || grid.moving(),
maxScroll: () => maxScrollPx, // how far the last drawn body can scroll (px)
shownScroll: () => scrollPx, // where it actually is (after clamping)
pxPerV: H, // canvas pixels per unit of v (for drag scrolling)
+38 -1
View File
@@ -4,6 +4,7 @@
//
// ns.vrSymbols.draw(g, name, cx, cy, size, filled) centred on (cx, cy), `size` px square;
// returns false for an unknown name
// ns.vrSymbols.drawIcon(g, icon, cx, cy, size, filled) icon: a name, or { name, color, filled }
// ns.vrSymbols.has(name), ns.vrSymbols.NAMES
//
// Material Symbols: Copyright Google LLC, Apache License 2.0
@@ -44,6 +45,12 @@
'3d_rotation': [
'M620-360H520q-8 0-14-6t-6-14v-200q0-8 6-14t14-6h100q17 0 28.5 11.5T660-560v160q0 17-11.5 28.5T620-360Zm-200 0h-90q-13 0-21.5-8.5T300-390q0-13 8.5-21.5T330-420h70v-40h-40q-8 0-14-6t-6-14q0-8 6-14t14-6h40v-40h-70q-13 0-21.5-8.5T300-570q0-13 8.5-21.5T330-600h90q17 0 28.5 11.5T460-560v160q0 17-11.5 28.5T420-360Zm140-60h40v-120h-40v120Zm280-20q-17 0-28.5-11.5T800-480q0-115-72.5-203T542-794l30 30q11 11 11 28t-11 28q-11 11-28 11t-28-11L387-837q-8-8-5-20t15-15q20-5 41-6.5t42-1.5q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 17-11.5 28.5T840-440ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-17 11.5-28.5T120-520q17 0 28.5 11.5T160-480q0 115 72.5 203T418-166l-30-30q-11-11-11-28t11-28q11-11 28-11t28 11l129 129q8 8 5 20t-15 15q-20 5-41 6.5T480-80Z',
'M620-360H520q-8 0-14-6t-6-14v-200q0-8 6-14t14-6h100q17 0 28.5 11.5T660-560v160q0 17-11.5 28.5T620-360Zm-200 0h-90q-13 0-21.5-8.5T300-390q0-13 8.5-21.5T330-420h70v-40h-40q-8 0-14-6t-6-14q0-8 6-14t14-6h40v-40h-70q-13 0-21.5-8.5T300-570q0-13 8.5-21.5T330-600h90q17 0 28.5 11.5T460-560v160q0 17-11.5 28.5T420-360Zm140-60h40v-120h-40v120Zm280-20q-17 0-28.5-11.5T800-480q0-115-72.5-203T542-794l30 30q11 11 11 28t-11 28q-11 11-28 11t-28-11L387-837q-8-8-4.5-20t14.5-15q20-5 41-6.5t42-1.5q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 17-11.5 28.5T840-440ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-17 11.5-28.5T120-520q17 0 28.5 11.5T160-480q0 115 72.5 203T418-166l-30-30q-11-11-11-28t11-28q11-11 28-11t28 11l129 129q8 8 4.5 20T563-88q-20 5-41 6.5T480-80Z'],
'2d_2': [
'M376-279H172q-11 0-18-7t-7-18v-32q0-5 1.5-10.5t6.5-8.5q51-41 84.5-68.5T292-478q16-22 24-41.5t8-46.5q0-23-17-39.5T266-622q-17 0-29.5 9.5T216-589q-7 13-20.5 18t-26.5-1q-14-7-17-22t3-29q14-32 45.5-49.5T266-690q63 0 97.5 32t34.5 90q0 37-14 69t-36 61q-23 31-46.5 50.5T249-346l2 3h125q14 0 23 9t9 23q0 14-9 23t-23 9Zm152-1q-17 0-28.5-11.5T488-320v-321q0-17 11.5-28.5T528-681h94q97 0 148.5 52T822-480q0 97-52 148.5T622-280h-94Zm36-72h54q66 0 97.5-31t31.5-97q0-66-31.5-97.5T618-609h-54v257Z',
'M376-279H172q-11 0-18-7t-7-18v-32q0-5 1.5-10.5t6.5-8.5q51-41 84.5-68.5T292-478q16-22 24-40.5t8-38.5q0-30-17-47.5T266-622q-17 0-29.5 9.5T216-589q-7 13-20.5 18t-26.5-1q-14-7-17-22t3-29q14-32 45.5-49.5T266-690q63 0 97.5 32t34.5 90q0 37-14 69t-36 61q-23 31-46.5 50.5T249-346l2 3h125q14 0 23 9t9 23q0 14-9 23t-23 9Zm152-1q-17 0-28.5-11.5T488-320v-321q0-17 11.5-28.5T528-681h94q97 0 148.5 52T822-480q0 97-52 148.5T622-280h-94Zm36-72h54q66 0 97.5-31t31.5-97q0-66-31.5-97.5T618-609h-54v257Z'],
'3d_2': [
'M530-279q-17 0-28.5-11.5T490-319v-321q0-17 11.5-28.5T530-680h93q97 0 148.5 51.5T823-480q0 97-52 149t-148 52h-93Zm35-72h55q66 0 97-31.5t31-97.5q0-66-31-97.5T620-609h-55v258Zm-289 81q-45 0-78-17.5T149-340q-7-14-1-29t20-22q14-7 28.5-1.5T219-373q8 16 22.5 24.5T276-340q28 0 45-15.5t17-42.5q0-26-18-43t-51-17h-2q-14 0-23-9t-9-23q0-14 9-23t23-9q30 0 45-14t15-37q0-23-14.5-36.5T274-623q-18 0-31 8t-21 22q-7 14-21 18t-28-3q-14-7-18-21t3-28q6-12 14.5-21.5T192-666q16-11 37-17t46-6q55 0 89.5 27.5T399-581q0 30-13.5 50.5T350-496v4q30 11 46 39t16 61q0 59-37.5 90.5T276-270Z',
'M530-279q-17 0-28.5-11.5T490-319v-321q0-17 11.5-28.5T530-680h93q97 0 148.5 51.5T823-480q0 97-52 149t-148 52h-93Zm35-72h55q66 0 97-31.5t31-97.5q0-66-31-97.5T620-609h-55v258Zm-289 81q-45 0-78-17.5T149-340q-7-14-1-29t20-22q14-7 28.5-1.5T219-373q8 16 22.5 24.5T276-340q28 0 45-15.5t17-42.5q0-26-18-43t-51-17h-2q-14 0-23-9t-9-23q0-14 9-23t23-9q30 0 45-14t15-37q0-23-14.5-36.5T274-623q-18 0-31 8t-21 22q-7 14-21 18t-28-3q-14-7-18-21t3-28q6-12 14.5-21.5T192-666q16-11 37-17t46-6q55 0 89.5 27.5T399-581q0 30-13.5 50.5T350-496v4q30 11 46 39t16 61q0 59-37.5 90.5T276-270Z'],
account_box: [
'M200-246q54-53 125.5-83.5T480-360q83 0 154.5 30.5T760-246v-514H200v514Zm280-194q58 0 99-41t41-99q0-58-41-99t-99-41q-58 0-99 41t-41 99q0 58 41 99t99 41ZM200-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-120H200Zm80-80h400v-10q-42-35-93-52.5T480-280q-56 0-107 17.5T280-210v10Zm200-320q-25 0-42.5-17.5T420-580q0-25 17.5-42.5T480-640q25 0 42.5 17.5T540-580q0 25-17.5 42.5T480-520Zm0 17Z',
'M480-440q58 0 99-41t41-99q0-58-41-99t-99-41q-58 0-99 41t-41 99q0 58 41 99t99 41ZM200-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-80h560v-46q-54-53-125.5-83.5T480-360q-83 0-154.5 30.5T200-246v46Z'],
@@ -56,12 +63,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'],
@@ -107,6 +123,9 @@
comment: [
'M280-400h400q17 0 28.5-11.5T720-440q0-17-11.5-28.5T680-480H280q-17 0-28.5 11.5T240-440q0 17 11.5 28.5T280-400Zm0-120h400q17 0 28.5-11.5T720-560q0-17-11.5-28.5T680-600H280q-17 0-28.5 11.5T240-560q0 17 11.5 28.5T280-520Zm0-120h400q17 0 28.5-11.5T720-680q0-17-11.5-28.5T680-720H280q-17 0-28.5 11.5T240-680q0 17 11.5 28.5T280-640ZM160-240q-33 0-56.5-23.5T80-320v-480q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v623q0 27-24.5 37.5T812-148l-92-92H160Zm594-80 46 45v-525H160v480h594Zm-594 0v-480 480Z',
'M280-400h400q17 0 28.5-11.5T720-440q0-17-11.5-28.5T680-480H280q-17 0-28.5 11.5T240-440q0 17 11.5 28.5T280-400Zm0-120h400q17 0 28.5-11.5T720-560q0-17-11.5-28.5T680-600H280q-17 0-28.5 11.5T240-560q0 17 11.5 28.5T280-520Zm0-120h400q17 0 28.5-11.5T720-680q0-17-11.5-28.5T680-720H280q-17 0-28.5 11.5T240-680q0 17 11.5 28.5T280-640ZM160-240q-33 0-56.5-23.5T80-320v-480q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v623q0 27-24.5 37.5T812-148l-92-92H160Z'],
diamond: [
'M480-165q-17 0-33-7.5T419-194L113-560q-9-11-13.5-24T95-611q0-9 1.5-18.5T103-647l75-149q11-20 29.5-32t41.5-12h462q23 0 41.5 12t29.5 32l75 149q5 8 6.5 17.5T865-611q0 14-4.5 27T847-560L541-194q-12 14-28 21.5t-33 7.5Zm-95-475h190l-60-120h-70l-60 120Zm55 347v-267H218l222 267Zm80 0 222-267H520v267Zm144-347h106l-60-120H604l60 120Zm-474 0h106l60-120H250l-60 120Z',
'M368-630h224L486-840h-12L368-630Zm82 474v-414H105l345 414Zm60 0 345-414H510v414Zm148-474h207l-83-166q-11-20-29.5-32T711-840H554l104 210Zm-563 0h207l104-210H249q-23 0-41.5 12T178-796L95-630Z'],
drag_handle: [
'M200-360q-17 0-28.5-11.5T160-400q0-17 11.5-28.5T200-440h560q17 0 28.5 11.5T800-400q0 17-11.5 28.5T760-360H200Zm0-160q-17 0-28.5-11.5T160-560q0-17 11.5-28.5T200-600h560q17 0 28.5 11.5T800-560q0 17-11.5 28.5T760-520H200Z',
'M200-360q-17 0-28.5-11.5T160-400q0-17 11.5-28.5T200-440h560q17 0 28.5 11.5T800-400q0 17-11.5 28.5T760-360H200Zm0-160q-17 0-28.5-11.5T160-560q0-17 11.5-28.5T200-600h560q17 0 28.5 11.5T800-560q0 17-11.5 28.5T760-520H200Z'],
@@ -122,6 +141,9 @@
explore: [
'M480-440q-17 0-28.5-11.5T440-480q0-17 11.5-28.5T480-520q17 0 28.5 11.5T520-480q0 17-11.5 28.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320ZM297-277l250-117q6-3 11-8t8-11l117-250q5-10-2.5-17.5T663-683L413-566q-6 3-11 8t-8 11L277-297q-5 10 2.5 17.5T297-277Z',
'M480-440q-17 0-28.5-11.5T440-480q0-17 11.5-28.5T480-520q17 0 28.5 11.5T520-480q0 17-11.5 28.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0 0q-134 0-227-93t-93-227q0-134 93-227t227-93q134 0 227 93t93 227q0 134-93 227t-227 93Zm67-234q6-3 11-8t8-11l117-250q5-10-2.5-17.5T663-683L413-566q-6 3-11 8t-8 11L277-297q-5 10 2.5 17.5T297-277l250-117Z'],
fast_forward: [
'M100-315v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T428-480q0 10-4.5 18.5T410-447L162-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Zm400 0v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T828-480q0 10-4.5 18.5T810-447L562-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29ZM180-480Zm400 0Zm-400 90 136-90-136-90v180Zm400 0 136-90-136-90v180Z',
'M100-315v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T428-480q0 10-4.5 18.5T410-447L162-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Zm400 0v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T828-480q0 10-4.5 18.5T810-447L562-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Z'],
favorite: [
'M480-147q-14 0-28.5-5T426-168l-69-63q-106-97-191.5-192.5T80-634q0-94 63-157t157-63q53 0 100 22.5t80 61.5q33-39 80-61.5T660-854q94 0 157 63t63 157q0 115-85 211T602-230l-68 62q-11 11-25.5 16t-28.5 5Zm-38-543q-29-41-62-62.5T300-774q-60 0-100 40t-40 100q0 52 37 110.5T285.5-410q51.5 55 106 103t88.5 79q34-31 88.5-79t106-103Q726-465 763-523.5T800-634q0-60-40-100t-100-40q-47 0-80 21.5T518-690q-7 10-17 15t-21 5q-11 0-21-5t-17-15Zm38 189Z',
'M480-147q-14 0-28.5-5T426-168l-69-63q-106-97-191.5-192.5T80-634q0-94 63-157t157-63q53 0 100 22.5t80 61.5q33-39 80-61.5T660-854q94 0 157 63t63 157q0 115-85 211T602-230l-68 62q-11 11-25.5 16t-28.5 5Z'],
@@ -182,6 +204,9 @@
notifications_active: [
'M200-200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-83 50-147.5T420-792v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 84.5T720-560v280h40q17 0 28.5 11.5T800-240q0 17-11.5 28.5T760-200H200Zm280-300Zm0 420q-33 0-56.5-23.5T400-160h160q0 33-23.5 56.5T480-80ZM320-280h320v-280q0-66-47-113t-113-47q-66 0-113 47t-47 113v280ZM120-560q-17 0-28.5-13T82-603q8-75 42-139.5T211-855q13-11 29.5-10t26.5 15q10 14 8 30t-15 28q-39 37-64 86t-33 106q-2 17-14 28.5T120-560Zm720 0q-17 0-29-11.5T797-600q-8-57-33-106t-64-86q-13-12-15-28t8-30q10-14 26.5-15t29.5 10q53 48 87 112.5T878-603q2 17-9.5 30T840-560Z',
'M200-200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-83 50-147.5T420-792v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 84.5T720-560v280h40q17 0 28.5 11.5T800-240q0 17-11.5 28.5T760-200H200ZM480-80q-33 0-56.5-23.5T400-160h160q0 33-23.5 56.5T480-80ZM120-560q-17 0-28.5-13T82-603q8-75 42-139.5T211-855q13-11 29.5-10t26.5 15q10 14 8 30t-15 28q-39 37-64 86t-33 106q-2 17-14 28.5T120-560Zm720 0q-17 0-29-11.5T797-600q-8-57-33-106t-64-86q-13-12-15-28t8-30q10-14 26.5-15t29.5 10q53 48 87 112.5T878-603q2 17-9.5 30T840-560Z'],
notifications_off: [
'M646-200H200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-33 8.5-65t25.5-61l60 60q-7 16-10.5 32.5T320-560v280h248L84-764q-11-11-11-28t11-28q11-11 28-11t28 11l680 680q11 11 11.5 27.5T820-84q-11 11-28 11t-28-11L646-200ZM540-792q80 20 130 84.5T720-560v110q0 20-12.5 30T680-410q-15 0-27.5-10.5T640-451v-109q0-66-47-113t-113-47q-16 0-34 4t-32 10q-17 7-33.5 3T355-722q-8-13-5.5-27.5T365-771q13-7 27-12t28-9v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28Zm-96 388Zm36 324q-30 0-53.5-16.5T403-141q0-8 6.5-13.5T424-160h112q8 0 14.5 5.5T557-141q0 28-23.5 44.5T480-80Zm33-481Z',
'M646-200H200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-33 8.5-65t25.5-61l126 126H288L84-764q-11-11-11-28t11-28q11-11 28-11t28 11l680 680q11 11 11.5 27.5T820-84q-11 11-28 11t-28-11L646-200Zm74-251q0 12-7 22t-18 15q-11 5-23 2.5T652-422L367-707q-7-7-10-15t-3-17q0-11 5.5-21.5T375-776q11-5 22-9t23-7v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 85t50 147v109ZM480-80q-30 0-53.5-16.5T403-141q0-8 6.5-13.5T424-160h112q8 0 14.5 5.5T557-141q0 28-23.5 44.5T480-80Z'],
open_in_full: [
'M160-120q-17 0-28.5-11.5T120-160v-240q0-17 11.5-28.5T160-440q17 0 28.5 11.5T200-400v144l504-504H560q-17 0-28.5-11.5T520-800q0-17 11.5-28.5T560-840h240q17 0 28.5 11.5T840-800v240q0 17-11.5 28.5T800-520q-17 0-28.5-11.5T760-560v-144L256-200h144q17 0 28.5 11.5T440-160q0 17-11.5 28.5T400-120H160Z',
'M160-120q-17 0-28.5-11.5T120-160v-240q0-17 11.5-28.5T160-440q17 0 28.5 11.5T200-400v144l504-504H560q-17 0-28.5-11.5T520-800q0-17 11.5-28.5T560-840h240q17 0 28.5 11.5T840-800v240q0 17-11.5 28.5T800-520q-17 0-28.5-11.5T760-560v-144L256-200h144q17 0 28.5 11.5T440-160q0 17-11.5 28.5T400-120H160Z'],
@@ -359,5 +384,17 @@
return true;
}
ns.vrSymbols = { draw, has: name => name in PATHS, NAMES: Object.keys(PATHS) };
// An icon given as a name, or as { name, color (a colour spec: tokens.js resolve, e.g.
// 'primary' or '#ff0033'), filled }: drawn like draw(), in its own colour when it has one
// (e.g. a site's mark that keeps its brand colour in a menu row).
function drawIcon(g, icon, cx, cy, size, filled = false) {
if (!icon || typeof icon === 'string') return draw(g, icon, cx, cy, size, filled);
g.save();
if (icon.color) g.fillStyle = ns.vrTokens.resolve(icon.color);
const ok = draw(g, icon.name, cx, cy, size, icon.filled != null ? !!icon.filled : filled);
g.restore();
return ok;
}
ns.vrSymbols = { draw, drawIcon, has: name => name in PATHS, NAMES: Object.keys(PATHS) };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+202 -59
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?, disabled? }] },
// live: null | { atHead, behind (seconds), dvr } (a live stream; time
// and duration then cover only the rewindable part),
// speed, speeds: [rates], volume: { level, muted },
@@ -27,7 +27,7 @@
// intensity (0..1) }] (how much each part is rewatched; optional),
// ad: null | { ... },
// format: { current, options: [{ id, label, short, disabled }] },
// extras: { left: [...], right: [...] } (the site's own buttons on
// extras: { left: [...], right: [...], seek } (the site's own buttons on
// the controls panel: panel-ui.js, plus menu: null | { items: [{ id,
// text, on, disabled }], note, scroll, width, stay (stays open on a
// choice) }: opens like Quality's),
@@ -127,8 +127,14 @@
close: () => { if (isOpen(reg.id)) toggleOpen(reg.id); },
isOpen: () => isOpen(reg.id),
closeMenu: () => { menu = null; },
panel: id => controller(id), // another of the site's panels' controllers (e.g. to show something in it)
// Open (or close, if it's open) a floating menu on one of this panel's controls
// (`local`, e.g. 'action:bell'; the style needs rectOf). Its items come from the
// controller's menu(local) each frame; a choice arrives as { type: 'menu', value:
// item id, menu: local }.
openMenu: local => { const id = `p:${reg.id}${SEP}${local}`; menu = menu === id ? null : id; lastActivity = performance.now(); },
confirm: d => confirm(d),
typeInto: on => typeInto(p, on),
typeInto: (on, opts) => typeInto(p, on, opts),
});
panels.set(reg.id, p);
}
@@ -146,12 +152,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) {
@@ -168,6 +180,12 @@
const SEP = '|';
const panelOf = id => (id && id.includes(SEP) ? panels.get(id.slice(0, id.indexOf(SEP))) : null);
const localOf = (id, p) => (id && id.startsWith(p.reg.id + SEP) ? id.slice(p.reg.id.length + 1) : null);
// A side panel's menu id ('p:<panel>|<control>', host.openMenu) -> { p, local } or null.
function panelMenu(id) {
if (!id || !id.startsWith('p:')) return null;
const rest = id.slice(2), i = rest.indexOf(SEP), p = i < 0 ? null : panels.get(rest.slice(0, i));
return p ? { p, local: rest.slice(i + 1) } : null;
}
// Tell a panel's controller one of its controls was used: '<type>:<value>' -> event.
function sendEvent(p, local, extra) {
const i = local.indexOf(':');
@@ -220,7 +238,18 @@
// Where the menu goes: its bottom middle just above the button that opened it (centred
// on it, kept from reaching far past the panel's sides), in the controls panel's tilt,
// 32 dp in front.
// A side panel's menu ('p:<panel>|<control>') goes the same way above its control, in
// that panel's pose.
function menuModel(id) {
const pm = panelMenu(id);
if (pm) {
const r = pm.p.style.rectOf && pm.p.style.rectOf(pm.local), c = pm.p.style.canvas, sz = pm.p.size;
if (!r) return null;
const half = Math.max(0, sz[0] / 2 - 0.12);
const x = Math.max(-half, Math.min(half, ((r.x + r.w / 2) / c.width - 0.5) * sz[0]));
const bottom = (0.5 - r.y / c.height) * sz[1] + 12 * MPX;
return M.multiply(panelModel(pm.p), M.translation(x, bottom + menuSize[1] / 2, dp(32)));
}
const rect = ui.rectOf(id);
if (!rect) return null;
const cx = ((rect.x + rect.w / 2) / ui.width - 0.5) * size[0];
@@ -243,7 +272,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 +313,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 +408,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 +417,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 +454,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 +517,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 +565,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 +599,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;
}
@@ -560,6 +663,13 @@
function click(id) {
const p = panelOf(id);
if (p) { sendEvent(p, localOf(id, p)); return; }
if (id.startsWith('pi:')) { // an item of a side panel's menu: 'pi:<index>'
const pm = panelMenu(menu), mm = pm && pm.p.ctrl.menu ? pm.p.ctrl.menu(pm.local) : null;
const it = mm && (mm.items || [])[+id.slice(3)];
if (it && !it.disabled) pm.p.ctrl.event({ type: 'menu', value: it.id, menu: pm.local });
if (!(mm && mm.stay)) menu = null;
return;
}
if (id.startsWith('toggle:')) {
const pid = id.slice(7);
toggleOpen(pid);
@@ -663,6 +773,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 +803,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 +818,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 +940,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 +962,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 +1083,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,21 +1107,22 @@
}
// 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, disabled: !!q.disabled })),
note: quality.levels.length ? null : tr('panel.noQualities', { site: site.name || tr('panel.theSite') }),
};
} else if (menu === 'format') {
menuState = {
items: format.options.map(o => ({ id: `f:${o.id}`, text: o.short, on: o.id === format.current, disabled: o.disabled })),
items: format.options.map(o => ({ id: `f:${o.id}`, text: o.short, on: o.id === format.current, disabled: o.disabled, icons: [ns.vrPanelUI.formatIcon(o.id)] })),
note: null,
};
} else if (menu === 'speed') {
@@ -998,6 +1132,10 @@
const x = extraOf(menu.slice(2)), mm = x && x.menu;
if (mm) menuState = { ...mm, key: menu, items: (mm.items || []).map(it => ({ ...it, id: `xi:${x.id}:${it.id}` })) };
else menu = null; // the button went away
} else if (panelMenu(menu)) { // a side panel's menu (its items' ids: 'pi:<index>')
const pm = panelMenu(menu), mm = shown(pm.p) && pm.p.ctrl.menu ? pm.p.ctrl.menu(pm.local) : null;
if (mm) menuState = { ...mm, key: menu, items: (mm.items || []).map((it, i) => ({ ...it, id: `pi:${i}` })) };
else menu = null; // the panel closed, or has no menu there now
}
const volume = st.volume || { level: 100, muted: false };
muted = !!volume.muted;
@@ -1021,7 +1159,7 @@
quality, format: { id: format.current, label: currentFormat ? currentFormat.short : '' },
speed: st.speed || 1, volume: shownVolume, chapters: st.chapters || [], heatmap: st.heatmap || [], preview, ad,
menuOpen: menu, grabbing: !!grab,
extras: extrasNow ? { left: (extrasNow.left || []).map(stripMenu), right: (extrasNow.right || []).map(stripMenu) } : null,
extras: extrasNow ? { left: (extrasNow.left || []).map(stripMenu), right: (extrasNow.right || []).map(stripMenu), seek: extrasNow.seek || null } : null,
};
const pf = preview && preview.frame;
const key = [state.title, Math.floor(state.time), Math.floor(state.duration), state.paused, state.buffered != null ? Math.floor(state.buffered) : '',
@@ -1061,7 +1199,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 +1209,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 +1286,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 +1298,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
@@ -1173,7 +1314,9 @@
}
if (hoverShown && !kbShown && cpA > 0.01) items.push([hoverPass, hoverCardModel(hoverShown.rect), hoverSize, null, cpA]);
// The floating menu, growing up from its button (its canvas's bottom edge).
const MA = Math.min(1, Math.max(0, anim.menu.get())) * cpA, mm = MA > 0.01 && menuShown ? menuModel(menuShown) : null;
// (A side panel's menu fades with its panel's side instead of the controls panel.)
const menuA = panelMenu(menuShown) ? (panelMenu(menuShown).p.reg.stays ? 1 : V * B) : cpA;
const MA = Math.min(1, Math.max(0, anim.menu.get())) * menuA, mm = MA > 0.01 && menuShown ? menuModel(menuShown) : null;
if (mm) items.push([menuPass, scaled(mm, [0, -menuSize[1] / 2], shrink(anim.menu.get())), menuSize, backdrop, MA]);
// Settled ones first (writing depth), then the ones still fading.
items.sort((a, b) => (b[4] > 0.99) - (a[4] > 0.99));
+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',
+39 -6
View File
@@ -1,12 +1,14 @@
// YouTube's feed panels, both in the Catalog style: Explore (YouTube's Home feed with its
// topic chips, then the Explore pages of YouTube's left menu) and You (your own feeds, signed
// in: Subscriptions, Your channel, History, Playlists, Watch later, Liked videos, Movies &
// in: Subscriptions, Channels (the ones you subscribe to: a click opens one in the Channel
// panel), Your channel, History, Playlists, Watch later, Liked videos, Movies &
// TV). Each shows its sources as tabs (the row slides when they don't all fit), the loaded
// videos (a pager that loads more pages as you page through) and the current page. A source
// that isn't built yet shows "Coming soon" with the construction icon. A source loads the
// first time it shows. Panel controllers: see panels.js.
// actions used: browseHome(chip) -> pager (youtube-home.js home(); p.chips after a page),
// browseSubscriptions() -> pager (youtube-subscriptions.js feed()), browseFeed(name) ->
// browseSubscriptions() -> pager (youtube-subscriptions.js feed()), browseChannels() ->
// pager (youtube-subscriptions.js channels()), browseFeed(name) ->
// pager (youtube-home.js feedVideos()), browsePlaylist(id) -> pager (youtube-content.js
// playlistVideos()), myChannelId() -> Promise of id | null (youtube-home.js),
// channelHeader(id) -> Promise of header (its tabs), channelList(id, tab) -> pager
@@ -21,8 +23,11 @@
// in the string tables, and with `note` the grey line under the tabs 'yt.browse.<id>Note'),
// icon (Material Symbols, symbols.js), and either load(actions, chip) (makes the pager;
// `list` is the playlist its videos play in) with optional chips, or soon (not built yet:
// "Coming soon"). chips: 'home' (the Home feed's topic chips, from its pager), or a fixed
// list of chip ids (named 'yt.channel.<id>'), the first one chosen at first.
// "Coming soon"). card (optional): how an item becomes a card (default: a video card);
// pick(host, item) (optional): what clicking one does (default: play it). chips: 'home'
// (the Home feed's topic chips, from its pager), 'sorts' (the sort choices its pager
// offers, p.sorts: [{ text, selected }]), or a fixed list of chip ids (named
// 'yt.channel.<id>'), the first one chosen at first.
const EXPLORE = [
{ id: 'home', icon: 'home', load: (a, chip) => a.browseHome(chip), chips: 'home' },
{ id: 'shorts', icon: 'smart_display', soon: true },
@@ -37,9 +42,28 @@
{ id: 'podcasts', icon: 'podcasts', soon: true },
{ id: 'memberships', icon: 'card_membership', soon: true },
];
// A subscribed channel (youtube-subscriptions.js channels()) as a card: its round picture,
// name, handle · subscribers, and the bell's setting when YouTube offers one; a flag with
// its section's title when it's listed apart (e.g. "Purchased": a channel you're a member of).
function channelCard(c) {
const n = c.notifications || {}, cur = String(n.current || '').toLowerCase();
const now = (n.options || []).find(o => o.selected) || (n.options || []).find(o => cur && cur.includes(String(o.label).toLowerCase()));
const bell = now ? now.label : '';
return { title: c.title, thumb: c.thumb, fit: 'round', flags: c.aside ? [{ text: c.aside, bg: 'tertiary', fg: 'surface' }] : [],
lines: [{ text: [c.handle, c.subscribers].filter(Boolean).join(' · '), badge: c.badge || null },
...(bell ? [{ text: tr('yt.bell.line', { name: bell }) }] : [])] };
}
// Clicking a channel: show it in the Channel panel (left of the zone).
function showChannel(host, c) {
const ch = host.panel && host.panel('channel');
if (ch && ch.show) ch.show(c.id, { subscribed: true, notifications: c.notifications });
}
const MY_TABS = ['videos', 'shorts', 'live', 'playlists']; // Your channel's chips (those it has)
const YOU = [
{ id: 'subs', icon: 'subscriptions', load: a => a.browseSubscriptions(), note: true },
{ id: 'channels', icon: 'person', load: (a, sort) => a.browseChannels(sort), chips: 'sorts', note: true, card: channelCard, pick: showChannel },
{ id: 'mychannel', icon: 'account_box', load: (a, chip) => myChannel(a, chip, MY_TABS), chips: MY_TABS },
{ id: 'history', icon: 'history', load: a => a.browseFeed('history'), note: true },
{ id: 'playlists', icon: 'playlist_play', load: a => a.browseFeed('playlists'), note: true },
@@ -82,6 +106,7 @@
const perPage = host.perPage;
let source = sources[0].id;
let homeChips = []; // the Home feed's chip texts on offer, in display order
const sortsOf = {}; // source id -> the sort choices its pager offers (chips: 'sorts')
const chipOf = { home: 'All' }; // source id -> its chosen chip id
const availOf = {}; // source id -> the fixed chips it turned out to have (p.tabs)
let pager = null, page = 0, loading = false, error = '', ver = 0;
@@ -94,6 +119,7 @@
// A source's chips ([{ id, label }]) and its chosen one.
function chipList(s = current()) {
if (s.chips === 'home') return homeChips.map(t => ({ id: t, label: t }));
if (s.chips === 'sorts') return (sortsOf[s.id] || []).map(t => ({ id: t, label: t }));
return (availOf[s.id] || s.chips || []).map(id => ({ id, label: tr(`yt.channel.${id}`) }));
}
const chipNow = (s = current()) => chipOf[s.id] || (Array.isArray(s.chips) ? s.chips[0] : null);
@@ -113,6 +139,12 @@
loading = true; bump();
p.next().then(() => {
if (p === pager && current().chips === 'home' && p.chips && p.chips.length) homeChips = orderChips(p.chips);
// A list with its own sort choices (the Channels list): offered as its pills, the one
// it's sorted by chosen.
if (p === pager && current().chips === 'sorts' && p.sorts && p.sorts.length) {
sortsOf[source] = p.sorts.map(x => x.text);
const on = p.sorts.find(x => x.selected); if (on) chipOf[source] = on.text;
}
// A list that found out which of its chips exist (Your channel's tabs), and which it read.
if (p === pager && p.tabs && p.tabs.length) { availOf[source] = p.tabs; if (p.tab) chipOf[source] = p.tab; }
}, err => { if (p === pager) error = tr('yt.browse.failed', { error: err && err.message || err }); })
@@ -141,7 +173,8 @@
page = ev.value === 'next' ? Math.min(pageCount() - 1, page + 1) : Math.max(0, page - 1); bump();
} else if (ev.type === 'item') {
const it = items()[page * perPage + +ev.value];
if (it) actions.openVideo(it.id, it.playlistId || current().list);
if (it && current().pick) current().pick(host, it);
else if (it) actions.openVideo(it.id, it.playlistId || current().list);
}
}
@@ -157,7 +190,7 @@
title: tr(title), tabs: tabs(), tab: source,
pills: chips, pill: chips.length ? chipNow(s) : null,
note: s.note ? tr(`yt.browse.${source}Note`) : '',
items: items().slice(page * perPage, page * perPage + perPage).map(it => ns.ytVr.card(it)),
items: items().slice(page * perPage, page * perPage + perPage).map(it => (s.card ? s.card(it) : ns.ytVr.card(it))),
page, pageCount: pageCount(), more: !!pager && !pager.done, loading,
emptyText: s.soon ? tr('yt.comingSoon') : loading ? tr('yt.loading') : message || tr('yt.nothingYet'),
emptyIcon: s.soon ? 'construction' : null,
+65 -4
View File
@@ -1,7 +1,8 @@
// YouTube's "Channel" panel (Profile style): which channel is open, its header, the chosen
// tab and sort, the loaded videos (a pager that loads more pages as you page through), the
// current page, the channel's featured video (a "Featured" tab, first, when the channel
// has one), and the Subscribe button (unsubscribing asks in a dialog). Three tabs aren't
// has one), and the Subscribe button (unsubscribing asks in a dialog), with the notification
// bell beside it once subscribed (a round button opening a menu of YouTube's choices). Three tabs aren't
// video grids: Store (product cards, just to look at: buying leads off YouTube), Posts (a
// scrolling feed, more loading near its end) and About (a scrolling page). On a post you
// can like / dislike it and vote in its poll (signed in; shown at once, undone if YouTube
@@ -16,12 +17,16 @@
// openCurrent() show the playing video's channel
// showCurrent() the channel name was clicked: fold the panel if it
// shows that channel, else open it on it
// show(id, hint) show a channel picked elsewhere (the You panel's
// Channels list; hint: { subscribed, notifications })
// and open the panel
// noteSubscribed(id, on) a subscription changed elsewhere (controls panel)
// channelId() the channel shown (or null)
// actions used: channelHeader(id) -> Promise of header (youtube-channel.js channel()),
// channelList(id, tab, sort) -> pager (Store: Promise of [products]), channelAbout(id) ->
// Promise of about | null, channelFeatured(id) -> Promise of item | null,
// setSubscribed(id, on) -> Promise of { ok, error }, openVideo(id, playlistId), state(),
// setSubscribed(id, on) -> Promise of { ok, error }, setBell(option) -> Promise of { ok, error },
// bellMatches(label, state), openVideo(id, playlistId), state(),
// ratePost(post, what), votePoll(post, index, on) -> Promise of { ok, error },
// postComments(post, sort) -> pager, replies(comment), rateComment(comment, what).
(function (ns) {
@@ -73,6 +78,7 @@
const rated = new Map(); // post id -> 'liked' | 'disliked' | 'none' (your clicks)
const voted = new Map(); // post id -> the poll choice you picked (-1: none) (your clicks)
let sub = 'unknown'; // 'unknown' | 'subscribed' | 'unsubscribed' | 'busy'
let bell = null; // the channel's bell: { state, options: [{ label, params, selected }], busy } | null
let gen = 0, ver = 0; // gen: ignore answers for a channel/list no longer shown
const bump = () => { ver++; };
@@ -225,18 +231,22 @@
else if (k.length === 1) { search.text += k; bump(); }
}
function openChannel(id, subscribedHint) {
// bellHint: the channel's bell from elsewhere (the Channels list's notifications), until
// the header brings its own.
function openChannel(id, subscribedHint, bellHint) {
if (!id || id === channelId) return;
if (search) { searchFocus(false); search = null; }
channelId = id; header = null; error = ''; pager = null; page = 0; loading = false; featured = null; about = undefined; scroll = 0; opened = null;
tab = 'videos'; sort = SORTS.videos[0].id;
sub = subscribedHint == null ? 'unknown' : subscribedHint ? 'subscribed' : 'unsubscribed';
bell = bellOf(bellHint);
const my = ++gen;
bump();
Promise.resolve(actions.channelHeader(id)).then(h => {
if (my !== gen) return;
header = h;
if (h && h.subscribed != null && sub === 'unknown') sub = h.subscribed ? 'subscribed' : 'unsubscribed';
if (!bell && h) bell = bellOf(h.notifications);
const lists = tabs().filter(t => t.id !== 'featured');
if (!lists.some(t => t.id === tab) && lists.length) { tab = lists[0].id; sort = firstSort(tab); }
startList();
@@ -271,8 +281,43 @@
}, err => { if (id === channelId) { sub = before; console.warn('[streamxr] subscription change failed:', err); bump(); } });
}
// ---- the notification bell (signed in and subscribed, when YouTube offers one)
// The bell from a header's or the Channels list's `notifications` ({ state | current,
// options }), with the current choice marked (selected; else the one the state or the
// current label names), or null when it has no choices.
function bellOf(n) {
if (!n || !n.options || !n.options.length) return null;
const options = n.options.map(o => ({ ...o }));
if (!options.some(o => o.selected)) {
const now = options.find(o => n.state && actions.bellMatches && actions.bellMatches(o.label, n.state))
|| options.find(o => n.current && String(n.current).toLowerCase().includes(String(o.label).toLowerCase()));
if (now) now.selected = true;
}
return { options, busy: false };
}
// A choice's icon (Material Symbols) from its label (YouTube's words: All / Personalized / None).
const bellIcon = label => (/^all/i.test(label) ? 'notifications_active' : /none|off/i.test(label) ? 'notifications_off' : 'notifications');
// The bell's menu (host.openMenu('action:bell')): YouTube's choices, the current one ticked.
function menu(local) {
if (local !== 'action:bell' || !bell) return null;
return { note: tr('yt.bell.title'), items: bell.options.map(o => ({ id: o.params, text: o.label, on: !!o.selected, icons: [bellIcon(o.label)], disabled: bell.busy })) };
}
// Choose a bell setting; the tick moves only when YouTube's answer confirms it (or, with no
// state in the answer, when it says OK).
function chooseBell(params) {
const b = bell, o = b && b.options.find(x => x.params === params), id = channelId;
if (!o || o.selected || b.busy) return;
b.busy = true; bump();
Promise.resolve(actions.setBell(o)).then(res => {
if (res && res.ok) b.options.forEach(x => { x.selected = x === o; });
else console.warn('[streamxr] bell change failed:', res && res.error);
}, err => console.warn('[streamxr] bell change failed:', err)).finally(() => { b.busy = false; if (id === channelId) bump(); });
}
function event(ev) {
if (!channelId) return;
if (ev.type === 'action' && ev.value === 'bell') { if (bell) host.openMenu('action:bell'); return; }
if (ev.type === 'menu' && ev.menu === 'action:bell') { chooseBell(ev.value); return; }
// Searching: the search bar's controls and the typed keys.
if (ev.type === 'search') { startSearch(); return; }
if (search && !opened) {
@@ -346,6 +391,9 @@
stats: [h.handle, h.subscribers, h.videoCount].filter(Boolean).join(' · ') } : null,
loadingTitle: tr('yt.channel.loading'),
action: channelId ? { id: 'subscribe', look: BUTTON[sub].look, label: BUTTON[sub].key ? tr(BUTTON[sub].key) : '…' } : null,
// The bell, beside Subscribed: its icon shows the current choice.
iconAction: channelId && sub === 'subscribed' && bell ? { id: 'bell', look: bell.busy ? 'busy' : 'secondary',
icon: bellIcon((bell.options.find(o => o.selected) || { label: '' }).label) } : null,
tabs: tabs(), tab, pills: search ? [] : named(SORTS[tab] || []), pill: sort,
search: !search && !!(header && header.tabs && header.tabs.search), // the channel can be searched
field: search ? { id: 'query', text: search.text, placeholder: tr('yt.channel.searchIn', { name: (header && header.title) || tr('yt.channel.thisChannel') }),
@@ -381,7 +429,20 @@
host.open();
}
return { model, event, afterDraw, opened: openCurrent, openChannel, openCurrent, showCurrent, noteSubscribed, channelId: () => channelId };
// A channel picked elsewhere (the You panel's Channels list): show it and open the panel.
// hint: { subscribed, notifications } from that list, tab (optional): the tab to open on
// (e.g. 'posts'; the first one it has if it lacks that).
function show(id, hint = {}) {
if (id !== channelId) openChannel(id, hint.subscribed, hint.notifications);
if (hint.tab && SORTS[hint.tab] && hint.tab !== tab) {
closePost();
tab = hint.tab; sort = firstSort(tab);
if (header) startList(); else bump(); // (before the header arrives, its callback starts the list)
}
if (!host.isOpen()) host.open();
}
return { model, event, menu, afterDraw, opened: openCurrent, openChannel, openCurrent, showCurrent, show, noteSubscribed, channelId: () => channelId };
}
ns.ytVr = ns.ytVr || {};
+18 -4
View File
@@ -1,11 +1,12 @@
// YouTube's own buttons on the controls panel (the shell's "extras", vr-panel.js): Captions
// (left: a menu of Off and the video's caption tracks; the chosen track's lines are drawn in
// VR by the shell, captions-overlay.js) and Save (right: a scrolling menu of Watch later and
// VR by the shell, captions-overlay.js), Save (right: a scrolling menu of Watch later and
// your playlists, each ticked when the video is in it; a choice adds it or takes it out, and
// the menu stays open).
// the menu stays open), and Jump ahead (at the seek bar's end: YouTube Premium's jump to where
// most viewers skip to, marked with the Diamond).
//
// const ex = ns.ytVr.extras.create(); one per VR session (provider.js session())
// ex.state(st) -> { left: [captions], right: [save] } every frame (st: { ad, noVideo }: both
// ex.state(st) -> { left: [captions], right: [save], seek: jump | null } every frame (st: { ad, noVideo, paused, live }: all
// are greyed out during an ad or with no video playing; the provider's
// state); the buttons as panel-ui.js / vr-panel.js read them
// ex.extra(id, itemId) the shell's actions.extra: a button's menu opened (itemId null) or
@@ -72,10 +73,17 @@
menu: { items: list.map(o => ({ id: o.id, text: o.busy ? `${o.title}\n${tr('yt.save.busy')}` : o.title, on: o.saved, disabled: o.busy })),
note: save.loading && !list.length ? tr('yt.loading') : save.note || null, scroll: true, width: 520, stay: true },
};
return { left: [cc], right: [sv] };
// Jump ahead (YouTube Premium), at the seek bar's end (where Skip ad goes during ads),
// only while YouTube's own Jump ahead pill is on screen: the Diamond, its name,
// fast-forward; greyed out while paused.
const live = !!(st && st.live), none = !!(st && st.noVideo);
const ja = live || none || !youtube.jumpAheadOffered() ? null : { id: 'jump', icon: 'fast_forward', label: tr('yt.jumpAhead'), mark: premiumMark(),
disabled: !!(st && (st.paused || st.ad)) };
return { left: [cc], right: [sv], seek: ja };
}
function extra(id, itemId) {
if (id === 'jump') { youtube.jumpAhead(); return; }
if (id === 'captions' && itemId != null) {
const c = captions();
youtube.setCaptions(itemId === 'off' ? null : c.tracks[+itemId] || null);
@@ -92,5 +100,11 @@
}
ns.ytVr = ns.ytVr || {};
// YouTube Premium's mark: Material Symbols' Diamond, YouTube red when you have Premium, purple
// (an upsell, like YouTube's own) when you don't. An icon spec for the shell (symbols.js drawIcon).
const PREMIUM_RED = '#ff0033', UPSELL_PURPLE = '#a142f4';
function premiumMark() { return { name: 'diamond', color: youtube.hasPremium() ? PREMIUM_RED : UPSELL_PURPLE, filled: true }; }
ns.ytVr.extras = { create };
ns.ytVr.premiumMark = premiumMark;
})(globalThis.__streamxr = globalThis.__streamxr || {});
@@ -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 || {});
+17 -4
View File
@@ -11,12 +11,21 @@
// 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
// isOpen() (this panel), closeMenu() (the controls panel's open menu), openMenu(control)
// (a floating menu on one of this panel's controls, e.g. 'action:bell': its items from the
// controller's menu(control) -> { items: [{ id, text, on, disabled, icons }], note, stay },
// a choice arriving as { type: 'menu', value: item id, menu: control }), panel(id) (another
// panel's controller), 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' }) }.
@@ -34,6 +43,8 @@
// tick(frame) (optional) every frame, shown or not
// afterDraw() (optional) after the style drew the model
// opened() (optional) its toolbar item (or pill) opened the panel
// menu(control) (optional) while a menu it opened (host.openMenu) is up, every
// frame: what it lists (null closes it)
// ...anything else the controls panel calls (e.g. channel.openChannel) }
(function (ns) {
'use strict';
@@ -47,7 +58,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 },
+37 -6
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)
@@ -63,6 +63,25 @@
return [QUALITY_ICONS[q] || 'high_quality', ...(fps >= 50 ? ['60fps'] : [])];
}
// YouTube Premium's mark (extras.js premiumMark: the Diamond, red with Premium, purple without).
const diamond = () => ns.ytVr.premiumMark();
// The shell's quality state from youtube.quality(): the levels (YouTube's names and icons),
// with Premium's enhanced-bitrate one ('premium', e.g. "1080p Premium") just above its plain
// twin, marked with the Diamond and greyed out without Premium; it's the current one while
// it plays.
function qualityState(q) {
const p = q.premium;
const premiumLevel = p && { id: 'premium', label: p.label.replace(' ', '\n'), icons: [...qualityIcons(p.quality, q.labels).slice(0, 1), diamond()], disabled: !p.playable };
const levels = [];
for (const id of q.levels || []) {
if (premiumLevel && id === p.quality) levels.push(premiumLevel);
levels.push({ id, label: qualityName(id, q.labels), icons: qualityIcons(id, q.labels) });
}
if (p && p.on) return { current: 'premium', currentLabel: p.label, icons: premiumLevel.icons, levels };
return { current: q.current, currentLabel: qualityName(q.current, q.labels), icons: qualityIcons(q.current, q.labels), levels };
}
// Pick the format from player data (youtube.info(): projection[], spherical,
// partialSpherical, title, keywords, video = "WxH"):
// not marked spherical by YouTube -> cinema screen
@@ -224,6 +243,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
@@ -317,6 +340,10 @@
ratePost: (post, what) => ns.youtubeChannel.ratePost(post, what),
votePoll: (post, index, on) => ns.youtubeChannel.votePoll(post, index, on),
setSubscribed,
// A channel's notification bell: one of its notifications.options (youtube-subscriptions.js
// setNotifications -> { ok, confirmed, state, error }), and which choice a state means.
setBell: option => ns.youtubeSubscriptions.setNotifications(option),
bellMatches: (label, state) => ns.youtubeSubscriptions.bellMatches(label, state),
// Description panel: the transcript (opens and reads YouTube's transcript panel), the
// comments ('top' | 'newest') and a thread's replies (pagers), rating a comment.
transcript: () => ns.youtubeContent.transcript(),
@@ -330,6 +357,7 @@
search: query => ns.youtubeContent.search(query), // the Search panel
suggest: text => ns.youtubeHome.suggestions(text), // and its suggestions
browseSubscriptions: () => ns.youtubeSubscriptions.feed(),
browseChannels: sort => ns.youtubeSubscriptions.channels(sort), // You: Channels (sort: one of p.sorts' texts)
browseFeed: name => ns.youtubeHome.feedVideos(name),
browsePlaylist: id => ns.youtubeContent.playlistVideos(id),
myChannelId: () => ns.youtubeHome.myChannelId(), // You: Your channel
@@ -354,20 +382,21 @@
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: qualityState(q),
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:
channelId: ns.youtubeChannel.currentChannelId(), details, playlist: pl, description: description(),
recommendations: page.recommendations(),
playback: { loop: youtube.loop(), autoplay: !!youtube.autoplay() }, audio: audioTracks(),
extras: extras.state({ ad: ad() }),
extras: extras.state({ ad: ad(), paused: youtube.isPaused(), live: !!l }),
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 +406,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.
@@ -87,6 +88,11 @@
'yt.browse.mychannelMissing': 'Couldn’t find your channel. Are you signed in on YouTube?',
'yt.browse.subs': 'Subscriptions',
'yt.browse.subsNote': 'New videos from the channels you subscribe to',
'yt.browse.channels': 'Channels',
'yt.browse.channelsNote': 'The channels you subscribe to, A to Z. Pick one to open it.',
'yt.bell.title': 'Notifications',
'yt.bell.line': 'Notifications: {name}',
'yt.jumpAhead': 'Jump ahead',
'yt.browse.history': 'History',
'yt.browse.historyNote': 'Videos you’ve watched, newest first',
'yt.browse.playlists': 'Playlists',
@@ -121,6 +127,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.
@@ -77,6 +78,11 @@
'yt.browse.mychannelMissing': 'No se encontró tu canal. ¿Has iniciado sesión en YouTube?',
'yt.browse.subs': 'Suscripciones',
'yt.browse.subsNote': 'Vídeos nuevos de los canales a los que te suscribes',
'yt.browse.channels': 'Canales',
'yt.browse.channelsNote': 'Los canales a los que te suscribes, de la A a la Z. Elige uno para abrirlo.',
'yt.bell.title': 'Notificaciones',
'yt.bell.line': 'Notificaciones: {name}',
'yt.jumpAhead': 'Saltar adelante',
'yt.browse.history': 'Historial',
'yt.browse.historyNote': 'Vídeos que has visto, los más recientes primero',
'yt.browse.playlists': 'Listas',
@@ -111,6 +117,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',
+40 -1
View File
@@ -84,7 +84,8 @@
thumb: bestImage(g.thumbnailRenderer) };
}
// A post (backstagePostRenderer / postRenderer): { id, text, author, published, likes,
// A post (backstagePostRenderer / postRenderer): { id, text, author, channelId (the
// author's, '' if not given), avatar, published, likes,
// images: [urls], video: item | null, comments (the count, "882"), commentsParams (its
// own page, for postComments), likeState ('liked' | 'disliked' | 'none': yours),
// actions: { like, unlike, dislike, undislike } (endpoints for ratePost; null signed out),
@@ -109,6 +110,7 @@
dislike: usable(dislike.defaultServiceEndpoint), undislike: usable(dislike.toggledServiceEndpoint) };
return {
id: p.postId, text: strOf(p.contentText), author: strOf(p.authorText),
channelId: dig(p, 'authorEndpoint', 'browseEndpoint', 'browseId') || '', avatar: bestImage(p.authorThumbnail),
published: strOf(p.publishedTimeText), likes: strOf(p.voteCount),
images, video: att.videoRenderer ? D.fromCompact(att.videoRenderer) : null,
comments: strOf(reply.text), commentsParams: dig(reply, 'navigationEndpoint', 'browseEndpoint', 'params') || '',
@@ -162,6 +164,7 @@
// channel(id) -> Promise of { id, title, badge, handle, subscribers, videoCount,
// description (short), avatar, banner, links (e.g. "x.com/somechannel and 4 more links"),
// url, rssUrl, keywords, isFamilySafe, subscribed (true/false signed in, else null),
// notifications (the bell: { state, options } or null, see bellOf),
// tabs: { videos: params, ... } (only the tabs this channel has), aboutToken }. Cached
// for 5 minutes.
const headerCache = new Map(); // id -> { at, promise }
@@ -204,9 +207,45 @@
// Signed in: YouTube's saved subscription state (the same subscriptionStateEntity a
// Subscribe answer carries); null while signed out or when it isn't sent.
subscribed: (() => { const e = findKeyAll(r.frameworkUpdates, 'subscriptionStateEntity')[0]; return e && e.subscribed != null ? !!e.subscribed : null; })(),
notifications: bellOf(r),
};
}
// The header's notification bell, signed in and subscribed: { state ('ALL', 'OCCASIONAL',
// 'OFF' ...: the end of SUBSCRIPTION_NOTIFICATION_STATE_*, or ''), options: [{ label,
// params, selected }] } (the choices YouTube's own bell menu sends, as in the Manage list's
// notificationOptions), or null when the header has none. UNVERIFIED (written signed out):
// each choice is found as a modifyChannelNotificationPreferenceEndpoint inside the header,
// named by the nearest enclosing item with a title or text; the state is the
// subscriptionNotificationStateEntity the header's button points to (by its store key).
function bellOf(r) {
const options = [], seen = new Set();
const walk = (node, stack, depth) => {
if (!node || typeof node !== 'object' || depth > 40) return;
if (Array.isArray(node)) { for (const x of node) walk(x, stack, depth + 1); return; }
const ep = node.modifyChannelNotificationPreferenceEndpoint;
if (ep && ep.params) {
if (seen.has(ep.params)) return;
seen.add(ep.params);
const item = [...stack].reverse().find(a => strOf(a.title) || strOf(a.text));
if (item) options.push({ label: strOf(item.title) || strOf(item.text), params: ep.params, selected: !!(item.isSelected || item.selected) });
return;
}
stack.push(node);
for (const k of Object.keys(node)) walk(node[k], stack, depth + 1);
stack.pop();
};
walk(r.header, [], 0);
if (!options.length) return null;
const key = findKey(r.header, 'notificationStateEntityStoreKey');
const ents = (dig(r, 'frameworkUpdates', 'entityBatchUpdate', 'mutations') || [])
.filter(m => findKey(m.payload, 'subscriptionNotificationStateEntity'));
const ent = ents.find(m => key && m.entityKey === key) || (ents.length === 1 ? ents[0] : null);
const state = String(dig(findKey(ent && ent.payload, 'subscriptionNotificationStateEntity'), 'state') || '')
.replace(/^SUBSCRIPTION_NOTIFICATION_STATE_/, '');
return { state, options };
}
// The browse params for one of a channel's tabs (its own, or the usual ones).
const tabParams = async (channelId, name) => (await channel(channelId)).tabs[name] || TAB_PARAMS[name];
+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 || {});
+6 -3
View File
@@ -180,12 +180,15 @@
}
// In-page navigation through YouTube's app (no page reload). Returns false if unavailable.
// The event is sent from the page manager (inside the app), not the app element itself:
// sent from ytd-app it's ignored on some pages (a channel page, checked 2026-10-17), while
// from ytd-page-manager it works there and from the watch page alike.
function navigate(url, watchEndpoint) {
const app = document.querySelector('ytd-app');
if (!app) return false;
const from = document.querySelector('ytd-page-manager') || document.querySelector('ytd-app');
if (!from) return false;
const endpoint = { commandMetadata: { webCommandMetadata: { url, webPageType: 'WEB_PAGE_TYPE_WATCH', rootVe: 3832 } }, watchEndpoint };
try {
app.dispatchEvent(new CustomEvent('yt-navigate', { bubbles: true, composed: true, detail: { endpoint } }));
from.dispatchEvent(new CustomEvent('yt-navigate', { bubbles: true, composed: true, detail: { endpoint } }));
return true;
} catch (_) { return false; }
}
+64 -11
View File
@@ -55,30 +55,83 @@
// Video quality. YouTube ids: 'hd2160', 'hd1440', 'hd1080', 'hd720', 'large' (480p),
// 'medium' (360p), 'small', 'tiny', 'auto'. labels: YouTube's own name for each id where
// the player gives one (getAvailableQualityData's qualityLabel, e.g. "1080p60" on a 60 fps
// video; Premium's extra "1080p Premium" entry is skipped in favour of the plain one).
// video; Premium's extra entry is kept apart as `premium`).
// premium: YouTube Premium's enhanced-bitrate quality, or null when the video has none:
// { quality (its id, 'hd1080'), label ("1080p Premium"), formatId, playable (false: you
// don't have Premium), on (playing now) }. Seen signed in with Premium (2026-10-17): an
// entry with paygatedQualityDetails and its own formatId ("721") beside the plain
// 1080p's ("399"); getPlaybackQualityLabel() names the one playing.
// formats: the plain entries' formatIds by id, where the player gives one.
function quality() {
try {
const p = player();
const levels = (p && p.getAvailableQualityLevels && p.getAvailableQualityLevels()) || [];
const current = (p && p.getPlaybackQuality && p.getPlaybackQuality()) || null;
const labels = {};
const labels = {}, formats = {};
let premium = null;
const data = (p && p.getAvailableQualityData && p.getAvailableQualityData()) || [];
for (const d of data) {
if (!d || !d.quality || !d.qualityLabel) continue;
const premium = !!d.paygatedQualityDetails || /premium/i.test(d.qualityLabel);
if (!labels[d.quality] || !premium) labels[d.quality] = premium ? labels[d.quality] || d.qualityLabel : d.qualityLabel;
if (d.paygatedQualityDetails || /premium/i.test(d.qualityLabel)) {
if (!premium) premium = { quality: d.quality, label: d.qualityLabel, formatId: d.formatId || null, playable: d.isPlayable !== false, on: false };
continue;
}
if (!labels[d.quality]) { labels[d.quality] = d.qualityLabel; if (d.formatId) formats[d.quality] = d.formatId; }
}
return { current, levels, labels };
} catch (_) { return { current: null, levels: [], labels: {} }; }
if (premium) {
const now = (p.getPlaybackQualityLabel && p.getPlaybackQualityLabel()) || '';
premium.on = current === premium.quality && now === premium.label;
}
return { current, levels, labels, formats, premium };
} catch (_) { return { current: null, levels: [], labels: {}, formats: {}, premium: null }; }
}
// Choose a quality (one of the ids above). YouTube has changed which method works over
// time, so both are tried; whichever the player lacks is simply skipped.
// Choose a quality (one of the ids above, or 'premium': Premium's enhanced-bitrate one).
// Where the player gives a format id it's passed too (it tells the plain 1080p and 1080p
// Premium apart). YouTube has changed which method works over time, so both are tried;
// whichever the player lacks is simply skipped.
function setQuality(level) {
const p = player();
if (!p) return;
try { if (p.setPlaybackQualityRange) p.setPlaybackQualityRange(level, level); } catch (_) { /* older player */ }
try { if (p.setPlaybackQuality) p.setPlaybackQuality(level); } catch (_) { /* newer player */ }
const q = quality();
let id = level, formatId = q.formats[level];
if (level === 'premium') {
if (!q.premium || !q.premium.playable) return;
id = q.premium.quality; formatId = q.premium.formatId;
}
try { if (p.setPlaybackQualityRange) p.setPlaybackQualityRange(id, id, formatId || undefined); } catch (_) { /* older player */ }
if (level !== 'premium') try { if (p.setPlaybackQuality) p.setPlaybackQuality(id); } catch (_) { /* newer player */ }
}
// YouTube Premium's Jump ahead: to the point most viewers skip to. YouTube offers it as a pill
// in the player's "timely actions" overlay at the moments it applies (seen signed in with
// Premium, 2026-10-17: ytw-timely-action-view-model > button, its text "Jump ahead"), and a
// script's click on that pill jumps (17 s -> 124 s). The pill is found by its text, so this
// expects YouTube in English, like the other readers that match YouTube's words.
// jumpAheadOffered() -> is the pill on screen now? jumpAhead() clicks it (if it is).
function jumpAheadButton() {
const p = player();
if (!p) return null;
for (const b of p.querySelectorAll('.ytp-timely-actions-overlay button, ytw-timely-action-view-model button')) {
if (b.getClientRects().length && /jump ahead/i.test((b.getAttribute('aria-label') || '') + ' ' + (b.textContent || ''))) return b;
}
return null;
}
const jumpAheadOffered = () => !!jumpAheadButton();
function jumpAhead() {
const b = jumpAheadButton();
if (b) b.click();
}
// Does the signed-in account have YouTube Premium? The page's top-bar logo is YouTube
// Premium's (seen signed in with Premium, 2026-10-17: topbarLogoRenderer iconType
// 'YOUTUBE_PREMIUM_LOGO'; 'YOUTUBE_LOGO' otherwise).
function hasPremium() {
try {
const logo = window.ytInitialData && window.ytInitialData.topbar && window.ytInitialData.topbar.desktopTopbarRenderer
&& window.ytInitialData.topbar.desktopTopbarRenderer.logo;
return JSON.stringify(logo || {}).includes('YOUTUBE_PREMIUM_LOGO');
} catch (_) { return false; }
}
// Name of the channel that posted the video.
@@ -350,7 +403,7 @@
}
ns.youtube = {
player, video, call, toggle, seek, seekTo, isPaused, currentTime, duration, title, channel, quality, setQuality, info,
player, video, call, toggle, seek, seekTo, isPaused, currentTime, duration, title, channel, quality, setQuality, hasPremium, jumpAhead, jumpAheadOffered, info,
speeds, speed, setSpeed, volume, setVolume, setMuted,
captions, setCaptions, captionLines, audioTracks, setAudioTrack,
loop, setLoop, autoplay, setAutoplay, playlistPosition, nextVideo, previousVideo, playAt,
+78 -10
View File
@@ -126,19 +126,84 @@
return { current, options };
}
// channels() -> pager over the subscribed channels (the "Manage" page, browse id
// FEchannels), A-Z as YouTube lists them. p.message is the sign-in note while signed out.
function channels() {
// The Manage page's sort choices (YouTube's "A-Z" dropdown: Most relevant, New activity,
// A-Z): [{ text, selected, token }]. Seen on the signed-in page (2026-10-17): each choice is a
// listItemViewModel whose entityKey is 'channel_list_page_sort_chip_entity:<its text>';
// tapping it updates a chipEntity (the dropdown's label) and sends a browse continuation
// that reloads the list in that order. The chosen one is the chipEntity's text (or a choice
// marked isSelected).
const SORT_KEY = 'channel_list_page_sort_chip_entity:';
// Every value under `key` anywhere in `root`, however deep (the sort menu sits deeper than
// findKeyAll's 20 levels, in the dropdown's sheet).
function deepFind(root, key, out, depth = 0) {
if (!root || typeof root !== 'object' || depth > 80) return out;
if (Array.isArray(root)) { for (const x of root) deepFind(x, key, out, depth + 1); return out; }
for (const k of Object.keys(root)) { if (k === key) out.push(root[k]); else deepFind(root[k], key, out, depth + 1); }
return out;
}
function sortsOf(r) {
const items = deepFind(r, 'listItemViewModel', []).filter(it => String(it.entityKey || '').startsWith(SORT_KEY));
const chips = (dig(r, 'frameworkUpdates', 'entityBatchUpdate', 'mutations') || [])
.map(m => findKey(m.payload, 'chipEntity')).filter(Boolean);
const out = [];
for (const it of items) {
const text = strOf(it.title) || String(it.entityKey).slice(SORT_KEY.length);
if (!text || out.some(x => x.text === text)) continue;
const tap = dig(it, 'rendererContext', 'commandContext', 'onTap');
// The chipEntity this choice updates, and what that entity says now (the current choice).
const key = dig(findKey(tap, 'chipEntity'), 'key');
const now = chips.find(c => key && c.key === key);
out.push({ text, token: dig(findKey(tap, 'continuationCommand'), 'token') || null,
selected: !!(it.isSelected || it.selected || (now && strOf(now.text) === text)) });
}
return out;
}
// The channels on a Manage page, each with `group`: the title of the shelf it's in (YouTube
// splits the list into shelves, e.g. "Purchased" for channels you're a member of, then
// "Subscribed"; seen signed in, 2026-10-17), '' outside any, and `aside` (below).
function channelsOn(r) {
const out = [];
const shelves = C.findKeyAll(r, 'shelfRenderer');
for (const s of shelves) {
const group = strOf(s.title);
for (const c of C.findKeyAll(s, 'channelRenderer').map(fromChannel).filter(Boolean)) out.push({ ...c, group });
}
if (!shelves.length) out.push(...C.findKeyAll(r, 'channelRenderer').map(fromChannel).filter(Boolean).map(c => ({ ...c, group: '' })));
// `aside`: the group's title on the channels of every shelf but the last (YouTube's own
// list comes after them: "Subscribed"), so they can be told apart; '' on the rest.
const last = out.length ? out[out.length - 1].group : '';
for (const c of out) c.aside = c.group && c.group !== last ? c.group : '';
return out;
}
// channels(sort) -> pager over the subscribed channels (the "Manage" page, browse id
// FEchannels). sort: one of the page's sort choices by its text (e.g. 'A-Z'; default: the
// page's own order). After the first page p.sorts = [{ text, selected }] lists the real
// choices. p.message is the sign-in note while signed out.
function channels(sort) {
const p = D.pager(async token => {
const r = await D.innertube('browse', token ? { continuation: token } : { browseId: 'FEchannels' });
if (!token) p.message = signInMessage(r);
let r;
if (token) r = await D.innertube('browse', { continuation: token });
else {
r = await D.innertube('browse', { browseId: 'FEchannels' });
p.message = signInMessage(r);
const sorts = sortsOf(r);
p.sorts = sorts.map(({ text, selected }) => ({ text, selected }));
const s = sort && sorts.find(x => x.token && x.text.toLowerCase() === String(sort).toLowerCase() && !x.selected);
if (s) {
r = await D.innertube('browse', { continuation: s.token });
p.sorts = p.sorts.map(x => ({ text: x.text, selected: x.text === s.text }));
}
}
// Everything in this list is subscribed, whatever its button says (some only carry a
// stub that says false). notifications.options is [] when YouTube offers no bell.
const items = C.findKeyAll(r, 'channelRenderer').map(fromChannel).filter(Boolean)
.map(c => ({ ...c, subscribed: true }));
return { items, token: C.pageToken(r) };
const items = channelsOn(r).map(c => ({ ...c, subscribed: true }));
// (Its "load more" item can sit at the end of the last of several sections.)
const more = C.findKeyAll(r, 'continuationItemRenderer').pop();
return { items, token: C.pageToken(r) || (more ? D.continuationOf({ continuationItemRenderer: more }) : null) };
}, null);
p.message = '';
p.message = ''; p.sorts = [];
return p;
}
@@ -186,6 +251,9 @@
// Which reported states mean each menu label. Seen signed in: All = ALL, Personalized =
// OCCASIONAL (0.20.5). None wasn't seen yet, so close variants are accepted.
const BELL_STATES = { all: /^ALL$/, personalized: /PERSONALI[SZ]ED|DEFAULT|OCCASIONAL/, none: /OFF|NONE|NO_NOTIFICATIONS/ };
// Does a bell state ('ALL', 'OCCASIONAL' ...) mean the menu choice called `label`? (false
// for a label it doesn't know).
const bellMatches = (label, state) => { const re = BELL_STATES[String(label).toLowerCase()]; return !!(re && state && re.test(state)); };
// setNotifications(option) -> Promise of { ok, confirmed, state?, error? }: choose a bell
// setting, where `option` is one of a channel's notifications.options (from channels()).
@@ -204,5 +272,5 @@
} catch (err) { return { ok: false, confirmed: false, error: String(err && err.message || err) }; }
}
ns.youtubeSubscriptions = { feed, channels, subscribe, unsubscribe, setNotifications, fromChannel, checkSignIn, checkAccount, answeredSignedIn, signedIn: D.signedIn };
ns.youtubeSubscriptions = { feed, channels, subscribe, unsubscribe, setNotifications, bellMatches, fromChannel, checkSignIn, checkAccount, answeredSignedIn, signedIn: D.signedIn };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+26 -12
View File
@@ -85,7 +85,9 @@
// as uSize), d its distance
// outside a rounded rectangle of corner radius uRadius (Inigo Quilez's sdRoundBox); the
// last 4 mm inside the edge fade out (alpha, blended over what's behind without touching
// the frame's alpha: see draw), and outside it's discarded.
// the frame's alpha: see draw), and outside it's discarded. The video is read at this eye's
// part of the frame across (uUvX: scale, offset; the whole frame, or a 3D side-by-side
// frame's half).
const VS = `
attribute vec2 aCorner; // -0.5..0.5
uniform mat4 uProj, uView, uModel; uniform vec2 uSize; uniform float uBend;
@@ -101,14 +103,14 @@
gl_Position = uProj * uView * uModel * vec4(p, 1.0);
}`;
const FS = `
precision mediump float; uniform sampler2D uTex; uniform vec2 uScreen; uniform float uRadius; varying vec2 vUv;
precision mediump float; uniform sampler2D uTex; uniform vec2 uScreen, uUvX; uniform float uRadius; varying vec2 vUv;
void main() {
vec2 p = (vUv - 0.5) * uScreen;
vec2 q = abs(p) - uScreen * 0.5 + uRadius;
float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - uRadius;
float a = 1.0 - smoothstep(-0.004, 0.0, d);
if (a <= 0.0) discard;
gl_FragColor = vec4(texture2D(uTex, vUv).rgb, a);
gl_FragColor = vec4(texture2D(uTex, vec2(vUv.x * uUvX.x + uUvX.y, vUv.y)).rgb, a);
}`;
// The glow (WebGL 2 shaders, GLSL ES 3.00, for textureLod): the same strip, bigger (uSize,
@@ -120,7 +122,8 @@
// texture coordinate clamped to the picture) from the mipmap level about uDetail texels
// wide (blurred; a level further up further out: chosen here rather than by the GPU, which
// would pick the sharpest level where the clamped coordinate doesn't change), fading out to
// nothing at uReach metres. Added to what's behind (light), at uStrength.
// nothing at uReach metres. Added to what's behind (light), at uStrength. Read at this eye's
// part of the frame across (uUvX), as FS.
const GLOW_VS = `#version 300 es
in vec2 aCorner;
uniform mat4 uProj, uView, uModel; uniform vec2 uSize; uniform float uBend;
@@ -137,7 +140,7 @@
}`;
const GLOW_FS = `#version 300 es
precision mediump float;
uniform sampler2D uTex; uniform vec2 uGlow, uScreen; uniform float uRadius, uReach, uDetail, uStrength;
uniform sampler2D uTex; uniform vec2 uGlow, uScreen, uUvX; uniform float uRadius, uReach, uDetail, uStrength;
in vec2 vUv;
out vec4 outColor;
void main() {
@@ -149,6 +152,7 @@
float a = uStrength * (1.0 - t) * (1.0 - t);
if (a <= 0.002) discard;
vec2 uv = clamp(p / uScreen + 0.5, 0.0, 1.0);
uv.x = uv.x * uUvX.x + uUvX.y;
float lod = log2(max(1.0, float(textureSize(uTex, 0).x) / uDetail)) + t * 1.5;
outColor = vec4(textureLod(uTex, uv, lod).rgb, a);
}`;
@@ -201,18 +205,20 @@
return new Float32Array(out);
}
// Build the cinema screen renderer. Returns { upload, draw, place, dispose, interaction,
// Build the cinema screen renderer for format `fmt` ('flat', or 'flatsbs': 3D side by side,
// each eye its half of the frame). Returns { upload, draw, place, dispose, interaction,
// pauseAnchor, distance, setDistance, setCurve }: the same drawing interface as the video
// sphere, plus `interaction` (the laser-grabbable handles), `pauseAnchor` (where the pause
// symbol goes) and the Settings controls.
function createCinemaScreen(gl) {
function createCinemaScreen(gl, fmt = 'flat') {
const spans = ns.formats.eyeSpans(fmt); // which part of the frame each eye sees (3D side by side: its half)
const prog = G.createProgram(gl, VS, FS, 'cinema screen');
const hprog = G.createProgram(gl, VS, HANDLE_FS, 'cinema handles');
const isGL2 = typeof WebGL2RenderingContext !== 'undefined' && gl instanceof WebGL2RenderingContext;
const gprog = isGL2 ? G.createProgram(gl, GLOW_VS, GLOW_FS, 'cinema glow') : null; // (no glow on WebGL 1)
const U = G.uniforms(gl, prog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', tex: 'uTex', screen: 'uScreen', radius: 'uRadius' });
const U = G.uniforms(gl, prog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', tex: 'uTex', screen: 'uScreen', radius: 'uRadius', uvX: 'uUvX' });
const GU = gprog && G.uniforms(gl, gprog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', tex: 'uTex', glow: 'uGlow',
screen: 'uScreen', radius: 'uRadius', reach: 'uReach', detail: 'uDetail', strength: 'uStrength' });
screen: 'uScreen', radius: 'uRadius', reach: 'uReach', detail: 'uDetail', strength: 'uStrength', uvX: 'uUvX' });
const HU = G.uniforms(gl, hprog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', shape: 'uShape', arc: 'uArc', color: 'uColor', alpha: 'uAlpha' });
const vaos = G.vertexArrays(gl);
const corners = new Float32Array([-0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5]);
@@ -237,6 +243,12 @@
const prefs = loadPrefs();
let width = clampWidth(prefs.width || SETTINGS.width);
let aspect = 16 / 9;
// The screen's shape from the frame's (width / height): 3D side by side, one eye's picture:
// half the frame when it's two full pictures (wider than 2.5:1, e.g. 32:9), else the frame
// itself (half side by side: each eye squeezed into half of a 16:9 frame, shown at 16:9).
const eyeAspect = frame => (ns.formats.isSbs(fmt) && frame > 2.5 ? frame / 2 : frame);
// This eye's part of the frame across ([scale, offset]; a headset that names no eye: the left).
const eyeSpan = view => (view.eye === 'right' ? spans.right : spans.left);
let model = M.translation(0, 0, -SETTINGS.distance); // centre + orientation (+Z faces you)
let dist = clampDistance(prefs.distance || SETTINGS.distance);
let curve = curvePrefs(); // { on, amount }
@@ -322,7 +334,7 @@
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);
mipped = G.videoMipmaps(gl, ns.vrTokens.glass.on || glow.on);
if (video.videoWidth && video.videoHeight) aspect = video.videoWidth / video.videoHeight;
if (video.videoWidth && video.videoHeight) aspect = eyeAspect(video.videoWidth / video.videoHeight);
}
// What's behind the panels, for the glass look (vr-panel/panel-pass.js): the video's
@@ -336,7 +348,7 @@
if (!mipped) return null;
const g = gprog && glow.on && glow.strength > 0
? { strength: glow.strength, reach: height() * glow.reach, radius: width * SETTINGS.cornerRadius } : null;
return { kind: 'screen', tex, centre: centre(), right: axis(0), up: axis(1), normal: axis(2), size: [width, height()], bend: bend(), glow: g };
return { kind: 'screen', tex, centre: centre(), right: axis(0), up: axis(1), normal: axis(2), size: [width, height()], bend: bend(), glow: g, uvX: spans };
}
// The handles: { id, frame (a point on the screen's centre line, see edgeFrame), x, y
@@ -409,6 +421,7 @@
gl.uniform1f(GU.reach, reach);
gl.uniform1f(GU.detail, SETTINGS.glowDetail);
gl.uniform1f(GU.strength, glow.strength);
gl.uniform2fv(GU.uvX, eyeSpan(view));
gl.enable(gl.BLEND);
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE, gl.ZERO, gl.ONE);
gl.drawArrays(gl.TRIANGLES, 0, SETTINGS.columns * 6);
@@ -434,6 +447,7 @@
gl.uniform2f(U.size, width, height());
gl.uniform2f(U.screen, width, height());
gl.uniform1f(U.radius, width * SETTINGS.cornerRadius);
gl.uniform2fv(U.uvX, eyeSpan(view));
gl.uniform1f(U.bend, b);
gl.uniformMatrix4fv(U.proj, false, view.projectionMatrix);
gl.uniformMatrix4fv(U.view, false, view.transform.inverse.matrix);
@@ -567,7 +581,7 @@
// The right renderer for a format: cinema screen for flat videos, sphere for the rest.
function createVideoView(gl, fmt) {
return ns.formats.isFlat(fmt) ? createCinemaScreen(gl) : ns.videoSphere.createVideoSphere(gl, fmt);
return ns.formats.isFlat(fmt) ? createCinemaScreen(gl, fmt) : ns.videoSphere.createVideoSphere(gl, fmt);
}
// The global reset: forget the remembered size, placement and curve.
+12 -7
View File
@@ -9,9 +9,9 @@
const { formats, gl: G } = ns;
// Vertex shader: passes the vertex position along as the view direction (vDir) for the
// cubemap lookup, and applies uUvXform (scale xy, offset zw) to the texture coordinates;
// it's [1,1,0,0] ("whole frame") now that 3D layouts are gone, but it's where per-eye
// cropping would plug in.
// cubemap lookup, and applies uUvXform (scale xy, offset zw) to the texture coordinates:
// [1,1,0,0] for the whole frame, or one eye's half of a side-by-side 3D frame
// (formats.eyeSpans).
const VS = `
attribute vec3 aPos; attribute vec2 aUv;
uniform mat4 uProj, uView, uModel; uniform vec4 uUvXform;
@@ -97,6 +97,7 @@
const vao = vaos ? vaos.create() : null;
if (vaos) vaos.bind(vao);
const mesh = buildSphere(formats.longitudeSpan(fmt));
const spans = formats.eyeSpans(fmt); // which part of the frame each eye sees
const buffers = [
G.floatAttribute(gl, prog, 'aPos', mesh.pos, 3),
G.floatAttribute(gl, prog, 'aUv', mesh.uv, 2),
@@ -117,13 +118,14 @@
// What's behind the panels, for the glass look (vr-panel/panel-pass.js): the video's
// texture and how it's mapped ('sphere': equirectangular over `span` radians of
// longitude; 'eac': the cubemap), turned by `yaw`. Builds the mipmaps at once if the
// glass was just turned on. null while the glass is off.
// longitude, `uvX` each eye's part of the frame across: { left, right }; 'eac': the
// cubemap), turned by `yaw`. Builds the mipmaps at once if the glass was just turned on.
// null while the glass is off.
function backdrop() {
if (!ns.vrTokens.glass.on) return null;
if (!mipped) { gl.bindTexture(gl.TEXTURE_2D, tex); mipped = G.videoMipmaps(gl, true); }
if (!mipped) return null;
return { kind: formats.projectionMode(fmt) > 0.5 ? 'eac' : 'sphere', tex, span: formats.longitudeSpan(fmt), yaw: lastYaw };
return { kind: formats.projectionMode(fmt) > 0.5 ? 'eac' : 'sphere', tex, span: formats.longitudeSpan(fmt), uvX: spans, yaw: lastYaw };
}
// The video is "at infinity": only the head's rotation matters, not its position.
@@ -147,7 +149,10 @@
m[12] = m[13] = m[14] = 0; // drop the head's position, keep only its rotation
gl.uniformMatrix4fv(U.proj, false, view.projectionMatrix);
gl.uniformMatrix4fv(U.view, false, m);
gl.uniform4fv(U.xf, [1, 1, 0, 0]); // whole frame for both eyes (YouTube sends one eye)
// This eye's part of the frame (side by side: its half; a headset that names no eye
// gets the left one).
const [sx, ox] = view.eye === 'right' ? spans.right : spans.left;
gl.uniform4fv(U.xf, [sx, 1, ox, 0]);
gl.drawElements(gl.TRIANGLES, mesh.idx.length, gl.UNSIGNED_SHORT, 0);
}
if (vaos) vaos.bind(null);
+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)
+17 -8
View File
@@ -2,13 +2,16 @@
// both worlds (the panel lists the formats; the renderer needs the projection). Which
// format a video is, is decided by the site's provider (detectFormat).
//
// There are no 3D formats: on desktop YouTube sends one eye only, even for 3D videos.
// 3D: most 3D videos reach the desktop as one eye only (shown as mono); some arrive with both
// eyes side by side in one frame, which '180sbs' (VR180) and 'flatsbs' (the cinema screen)
// give each eye its half of. Nothing in the site's data marks those, so they're picked by hand
// (docs/youtube.md, "Formats and VR180").
(function (ns) {
'use strict';
// The four formats. The ids are used everywhere: 'mono' = one picture for both eyes;
// 'eac' = equi-angular cubemap.
const IDS = Object.freeze(['flat', '360mono', '360eac', '180mono']);
// The six formats. The ids are used everywhere: 'mono' = one picture for both eyes;
// 'eac' = equi-angular cubemap; 'sbs' = 3D side by side (left eye's picture on the left).
const IDS = Object.freeze(['flat', 'flatsbs', '360mono', '360eac', '180mono', '180sbs']);
const DEFAULT = 'flat'; // when nothing is known about the video
const isFormat = id => IDS.includes(id);
@@ -22,14 +25,20 @@
// sites/youtube/page/provider/provider.js.)
// Horizontal span of the sphere, in radians (the cinema screen doesn't use a sphere).
const longitudeSpan = fmt => (fmt === '180mono' ? Math.PI : Math.PI * 2);
const longitudeSpan = fmt => (fmt === '180mono' || fmt === '180sbs' ? Math.PI : Math.PI * 2);
// Which part of the frame each eye sees, across: [scale, offset] for the left eye and the
// right eye ([1, 0] = the whole frame; side by side, the left and right halves).
const isSbs = fmt => fmt === '180sbs' || fmt === 'flatsbs';
const eyeSpans = fmt => (isSbs(fmt) ? { left: [0.5, 0], right: [0.5, 0.5] } : { left: [1, 0], right: [1, 0] });
// How the sphere maps the frame: 0 = one wrap-around picture (equirectangular),
// 1 = YouTube's equi-angular cubemap.
const projectionMode = fmt => (fmt === '360eac' ? 1 : 0);
// Flat videos go on the cinema screen; everything else on the video sphere.
const isFlat = fmt => fmt === 'flat';
// Flat videos (2D, or 3D side by side) go on the cinema screen; everything else on the
// video sphere.
const isFlat = fmt => fmt === 'flat' || fmt === 'flatsbs';
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, projectionMode, isFlat };
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, eyeSpans, isSbs, projectionMode, isFlat };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+4
View File
@@ -15,13 +15,17 @@
// Video formats: the full names (the popup's picker) and short ones (the VR panel's
// Format button; \n = line break).
'format.flat': 'Cinema screen (regular video)',
'format.flatsbs': 'Cinema screen 3D side by side (both eyes in one frame)',
'format.360mono': '360°',
'format.360eac': '360° cubemap (YouTube "mesh")',
'format.180mono': '180° (VR180)',
'format.180sbs': '180° 3D side by side (both eyes in one frame)',
'format.short.flat': 'Cinema\nscreen',
'format.short.flatsbs': 'Cinema 3D\nside by side',
'format.short.360mono': '360°',
'format.short.360eac': '360°\ncubemap',
'format.short.180mono': '180°',
'format.short.180sbs': '180° 3D\nside by side',
// The popup on the video page, and the player's VR button.
'popup.button': 'Watch in VR (SteamVR)',
+4
View File
@@ -7,13 +7,17 @@
ns.i18n.add('es', {
// Video formats.
'format.flat': 'Pantalla de cine (vídeo normal)',
'format.flatsbs': 'Pantalla de cine 3D lado a lado (los dos ojos en un fotograma)',
'format.360mono': '360°',
'format.360eac': '360° cubemap (YouTube "mesh")',
'format.180mono': '180° (VR180)',
'format.180sbs': '180° 3D lado a lado (los dos ojos en un fotograma)',
'format.short.flat': 'Pantalla\nde cine',
'format.short.flatsbs': 'Cine 3D\nlado a lado',
'format.short.360mono': '360°',
'format.short.360eac': '360°\ncubemap',
'format.short.180mono': '180°',
'format.short.180sbs': '180° 3D\nlado a lado',
// The popup and the player's VR button.
'popup.button': 'Ver en VR (SteamVR)',
+17 -8
View File
@@ -3,13 +3,16 @@
// both worlds (the panel lists the formats; the renderer needs the projection). Which
// format a video is, is decided by the site's provider (detectFormat).
//
// There are no 3D formats: on desktop YouTube sends one eye only, even for 3D videos.
// 3D: most 3D videos reach the desktop as one eye only (shown as mono); some arrive with both
// eyes side by side in one frame, which '180sbs' (VR180) and 'flatsbs' (the cinema screen)
// give each eye its half of. Nothing in the site's data marks those, so they're picked by hand
// (docs/youtube.md, "Formats and VR180").
(function (ns) {
'use strict';
// The four formats. The ids are used everywhere: 'mono' = one picture for both eyes;
// 'eac' = equi-angular cubemap.
const IDS = Object.freeze(['flat', '360mono', '360eac', '180mono']);
// The six formats. The ids are used everywhere: 'mono' = one picture for both eyes;
// 'eac' = equi-angular cubemap; 'sbs' = 3D side by side (left eye's picture on the left).
const IDS = Object.freeze(['flat', 'flatsbs', '360mono', '360eac', '180mono', '180sbs']);
const DEFAULT = 'flat'; // when nothing is known about the video
const isFormat = id => IDS.includes(id);
@@ -23,14 +26,20 @@
// sites/youtube/page/provider/provider.js.)
// Horizontal span of the sphere, in radians (the cinema screen doesn't use a sphere).
const longitudeSpan = fmt => (fmt === '180mono' ? Math.PI : Math.PI * 2);
const longitudeSpan = fmt => (fmt === '180mono' || fmt === '180sbs' ? Math.PI : Math.PI * 2);
// Which part of the frame each eye sees, across: [scale, offset] for the left eye and the
// right eye ([1, 0] = the whole frame; side by side, the left and right halves).
const isSbs = fmt => fmt === '180sbs' || fmt === 'flatsbs';
const eyeSpans = fmt => (isSbs(fmt) ? { left: [0.5, 0], right: [0.5, 0.5] } : { left: [1, 0], right: [1, 0] });
// How the sphere maps the frame: 0 = one wrap-around picture (equirectangular),
// 1 = YouTube's equi-angular cubemap.
const projectionMode = fmt => (fmt === '360eac' ? 1 : 0);
// Flat videos go on the cinema screen; everything else on the video sphere.
const isFlat = fmt => fmt === 'flat';
// Flat videos (2D, or 3D side by side) go on the cinema screen; everything else on the
// video sphere.
const isFlat = fmt => fmt === 'flat' || fmt === 'flatsbs';
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, projectionMode, isFlat };
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, eyeSpans, isSbs, projectionMode, isFlat };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+4
View File
@@ -16,13 +16,17 @@
// Video formats: the full names (the popup's picker) and short ones (the VR panel's
// Format button; \n = line break).
'format.flat': 'Cinema screen (regular video)',
'format.flatsbs': 'Cinema screen 3D side by side (both eyes in one frame)',
'format.360mono': '360°',
'format.360eac': '360° cubemap (YouTube "mesh")',
'format.180mono': '180° (VR180)',
'format.180sbs': '180° 3D side by side (both eyes in one frame)',
'format.short.flat': 'Cinema\nscreen',
'format.short.flatsbs': 'Cinema 3D\nside by side',
'format.short.360mono': '360°',
'format.short.360eac': '360°\ncubemap',
'format.short.180mono': '180°',
'format.short.180sbs': '180° 3D\nside by side',
// The popup on the video page, and the player's VR button.
'popup.button': 'Watch in VR (SteamVR)',
+4
View File
@@ -8,13 +8,17 @@
ns.i18n.add('es', {
// Video formats.
'format.flat': 'Pantalla de cine (vídeo normal)',
'format.flatsbs': 'Pantalla de cine 3D lado a lado (los dos ojos en un fotograma)',
'format.360mono': '360°',
'format.360eac': '360° cubemap (YouTube "mesh")',
'format.180mono': '180° (VR180)',
'format.180sbs': '180° 3D lado a lado (los dos ojos en un fotograma)',
'format.short.flat': 'Pantalla\nde cine',
'format.short.flatsbs': 'Cine 3D\nlado a lado',
'format.short.360mono': '360°',
'format.short.360eac': '360°\ncubemap',
'format.short.180mono': '180°',
'format.short.180sbs': '180° 3D\nlado a lado',
// The popup and the player's VR button.
'popup.button': 'Ver en VR (SteamVR)',