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

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

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

## How

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

## Tested on

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

## Checklist

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

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

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

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

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

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

#### Commits (9)

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

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

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

</details>
<!-- pr-summary:end -->
2026-10-17 09:14:24 -07:00
38 changed files with 193 additions and 1051 deletions

No files matched your search

+1 -4
View File
@@ -18,9 +18,6 @@ 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").
@@ -118,7 +115,7 @@ for the code rules.
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`, `live-chat.js`),
registered in `panels.js`; the controls panel's Captions, Jump ahead and Save buttons are `extras.js`.
registered in `panels.js`; the controls panel's Captions and Save buttons are `extras.js`.
- New YouTube behavior goes in the provider or a panel controller; a style or the shell
never gets a YouTube field (add a generic option or slot instead).
- Tunables: `vr-panel/config.js`, `controllers/config.js`, `SETTINGS` in `cinema-screen.js`,
+3 -10
View File
@@ -156,7 +156,7 @@ src/sites/youtube/ YOUTUBE
provider/
provider.js the provider (interface at its top); registers YouTube in sites.js
panels.js the panel registry (each panel's zone, style, size, controller)
extras.js the controls panel's Captions and Save buttons and Jump ahead at the seek bar's end (the shell's "extras": left, right, seek), premiumMark() (the Diamond)
extras.js the controls panel's Captions and Save buttons (the shell's "extras")
strings-en.js the YouTube panels' words in English (strings-<lang>.js: other languages)
cards.js, badges.js YouTube items -> generic cards; the ✓ / ♪ badges
upnext.js, browse.js (Explore and You), search.js, channel.js, description.js, comments-panel.js, settings.js, live-chat.js
@@ -286,18 +286,11 @@ YouTube's Live chat uses all three (`available` while a live stream with a chat
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) }`
- `host`: `{ actions, style, perPage, open(), close(), isOpen(), closeMenu(), confirm(d), typeInto(on, opts) }`
(`opts.emojis`: the panel's own emojis, `[{ text, image, label }]`, first on the keyboard's emoji pages);
`confirm(d)` asks in the shell's dialog (`{ title, text (up to four lines), confirm, cancel
(null: no cancel button, a dialog that only informs), danger }` -> a Promise
of true / false); the site's `onControl(id, panel, shell)` gets the same as `shell.confirm`.
- Menus on side panels: `host.openMenu(control)` (e.g. `'action:bell'`) opens a floating menu
above that control, 32 dp in front, like the controls panel's (the style needs `rectOf(id)`;
Profile has it). While it's up, `vr-panel.js` asks the controller's `menu(control)` each frame
(`{ items: [{ id, text, on, disabled, icons }], note, stay }`, `null` closes it); a choice
arrives as `{ type: 'menu', value: item id, menu: control }`. The menu's id is
`'p:<panel>|<control>'` and its items `'pi:<index>'` (`panelMenu`). `host.panel(id)` is another
panel's controller (the You panel opens a channel in the Channel panel with it).
- Typing: a panel with a text field calls `host.typeInto(true)` to get the keys (and
`false` to give them back). One panel types at a time: when another asks, the one that had
the keys is sent `Escape` (as for a click on nothing) and should stop typing. While it's shown, each key reaches its controller as
@@ -326,7 +319,7 @@ control's id there is `<panel id>|<style id>`.
| **Info** (`info.js`) | Description, Comments, Live chat | header tabs; each tab one content kind: `text` (rich text, links), `paged` (rows in two columns: chapters), `rows` (scrolling, current row followed: transcript), `thread` (pills, count, rows with children and "show more": comments), `chat` (chat.js: a pinned banner, messages with inline pictures and coloured cards, following the newest or paused with a "jump to newest" button; optionally a message field with Send, reaction buttons and a Support button, an XP pill and a ranked Top fans list: live chat), `message` (optional loading indicator) |
| **Catalog** (`catalog.js`) | Explore, You, Search | title, source tabs, filter pills (tabs and left-aligned pills slide when they don't fit: kit.js createStrip) or a note or a text field with its button (and a suggestions list dropping down from it), card grid, paging; trims its height to its cards |
| **Queue** (`queue.js`) | Up Next | title or tabs, paging in the header, 3 x 2 cards, a "current" card |
| **Profile** (`profile.js`) | Channel | banner, picture, name + badge, stats line, one action button (looks: primary / secondary / danger / busy) and an optional round icon button beside it (the bell), tabs (shrinking to fit beside the pills), pills, a search button (searching: a back button and a text field in place of the tabs), card grid, paging, or one featured card, or a scrolling body: a text `page` (paragraphs, then label / value facts), a `feed` of posts (picture, author, text, pictures, a card, a poll with result bars, buttons with icons and counts), or one opened `post` with a back button and its comments (thread.js) |
| **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) |
| **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
-146
View File
@@ -6,152 +6,6 @@ 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/).
## 0.93.3
### 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
+44 -51
View File
@@ -7,35 +7,43 @@ taking your headset off. 🎬✨
**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) · [What's new](#-whats-new-in-v0933) · [Roadmap](#-roadmap) · [How you can help](#-how-you-can-help)
[Your privacy](#-your-privacy) · [What's new](#-whats-new-in-v0881) · [Roadmap](#-roadmap) · [How you can help](#-how-you-can-help)
> **Early access** (version 0.93). Free and open source, forever. It already works every day; it's
> **Early access** (version 0.88). Free and open source, forever. It already works every day; it's
> still growing fast, and your feedback shapes what comes next.
>
> StreamXR is vibe coded by one guy (me!), and the only headset I've got is the Steam Frame.
> Bug fixes, updates and support for more headsets and sites are coming, so please be
> patient with me. 😭🙏
## ✨ What's new in v0.93.3
## ✨ What's new in v0.88.1
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.
- 💬 **Live chat:** on a live stream, the chat opens on the right and stays up while you watch.
Read it with emojis, badges, Super Chats and the pinned message, tap the stream's
reactions (❤ 😄 🎉), and type messages on the VR keyboard, which now has emoji pages
(with your channel's member emojis). A **Support on desktop** button points you to YouTube's
window for Super Chats and memberships.
- 🏆 **Top fans:** your **XP** in the chat, the ranked list of the stream's top fans, and
the top three's #1 / #2 / #3 pills.
- ⏪ **Chat replays:** on a finished stream that kept its chat, the chat plays along with the
video. It pauses when you pause and follows when you jump back or ahead.
- ✋ **Move the side panels:** grip the grab bar beside either side's toolbar and put that side
where you like. Recentering keeps it there, unless you turn on Settings → Safe zone →
**Recenter resets panels**.
- 👆 **Hide everything in one pull** (suggested by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)): pull the trigger on empty space and the panels, toolbars
and search bar go away at once. Pull again to bring them back.
- ⏸️ **Settings → Auto-hide → Auto-hide while paused** (suggested by u/TehSuperFruitSnack too): pause and soak in a 180° / 360° scene
with nothing in the way (off by default).
- 🎚️ **Quality menu icons:** each quality shows its 4K / Full HD / HD (and 60 fps) icons, as the
Quality button does.
- 🖐️ **Clearer controller icons:** the grip shows a hand when it will grab something, and the
left D-pad shows a panel icon for show / hide.
- 🥽 **Meta Quest 2 reported working:** [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack) watches through SteamVR on a Quest 2, 8K videos included. Thank you! Other headsets: [tell me how yours does](#-adding-a-headset).
- 🩹 **Fixes:** the chat now tries again on its own when YouTube's page hasn't loaded it yet;
typing in the chat and the search bar no longer shows two text cursors; long dialog
messages are no longer cut off.
## 💡 Why I made this
@@ -103,8 +111,7 @@ 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. **3D side-by-side** videos play in
real 3D, as VR180 or on the cinema screen (pick it in the Format menu).
is detected automatically and can be changed any time.
- **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. 🌈
@@ -119,8 +126,7 @@ 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, and **1080p Premium** 💎 with Premium), **speed**, **volume**,
**format**, **recenter** and exit; **Jump ahead** 💎 at the seek bar's end when YouTube offers it.
- **Quality** (8K, 4K, 60 fps...), **speed**, **volume**, **format**, **recenter** and exit.
- **💬 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
@@ -134,8 +140,7 @@ 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 Channels with their
notification bells, Your channel,
"Immersive videos" included, plus News) and **You** (Subscriptions, Your channel,
History, Playlists, Watch later, Liked videos).
- 💬 **Live chat** on live streams (and **chat replays** on finished ones, playing along with the
video): it opens by itself and stays up while you watch. Read it with emojis, badges, Super
@@ -323,10 +328,8 @@ what bugs you most):
CC BY, CC BY-SA, CC BY-NC-SA or CC0), like the Steam Frame models' (CC BY-NC-SA 4.0).
**🎬 Video**
- **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).
- **No 3D (stereo) videos.** On desktop, YouTube only sends one eye's picture, so 3D videos
play flat.
- **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 😂
@@ -396,28 +399,18 @@ 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. 📋 **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"
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"
tabs.
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!).
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!).
### ⏳ The "as soon as it's possible" roadmap\*
@@ -534,7 +527,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/`). 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)).
`third_party/`). There are no 3D formats: on desktop YouTube sends one eye only.
| Read | For |
|---|---|
-70
View File
@@ -1,70 +0,0 @@
# 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.
-17
View File
@@ -276,20 +276,3 @@ 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).
+3 -16
View File
@@ -40,19 +40,15 @@ 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. [ ] **Posts in the feed**: deferred (0.90.2, see "Deferred"). The shelves (a Shorts row,
"Most relevant") are deferred too, as on Home.
2. [ ] **Shelves and posts in the feed** (small to medium)
- 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. [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)
3. [ ] **Manage list: your subscribed channels** (medium)
- 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. [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)
4. [ ] **Notification bell per channel** (medium)
- 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
@@ -82,12 +78,3 @@ 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.
+4 -19
View File
@@ -51,25 +51,10 @@ 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. 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.
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.
## Medium: new panels with scrolling or long text
+1 -1
View File
@@ -46,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", the Jump ahead pill). This is about what YouTube writes;
(the "All" chip, "Joined", an ad's "1 of 2"). 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.
+14 -62
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.93.3.
Last updated for 0.88.1.
## Top priority: before the first release
@@ -49,34 +49,16 @@ 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. 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).
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).
5. Commenting in-headset (writing comments and replies with the VR keyboard).
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.
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.
**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
@@ -86,48 +68,18 @@ 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`).
- [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).
- [ ] **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`).
- [ ] **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.
- [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`).
- [ ] **Subscriptions**: shelves and posts in the feed, the list of your channels, the bell per
channel (`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
+4 -40
View File
@@ -19,9 +19,6 @@ 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
@@ -39,15 +36,7 @@ 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). **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`).
account (the documented ones seem app-only).
- `youtube-chat.js` (`ns.youtubeChat`): a live stream's chat ("Live chat" below): `info()`
(its views), `open(token)` (a view's chat page), `poll(next)`, and the chat items as plain
messages and changes (add, remove, banner).
@@ -107,35 +96,10 @@ 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",
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.
skipped in favour of the plain entry. Unverified in the headset (see known-issues.md).
- 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).
- Desktop YouTube sends **one eye only**, even for 3D videos, so there are no 3D formats.
- Formats: `flat` (cinema screen), `360mono`, `360eac`, `180mono`.
- "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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "StreamXR",
"version": "0.93.3",
"version": "0.88.1",
"description": "Watch streaming video in a SteamVR headset: 360° and VR180 videos around you, regular videos on a cinema screen. Supports YouTube.",
"action": {
"default_title": "StreamXR: open YouTube and click the VR button (the headset)",
+11 -21
View File
@@ -38,14 +38,10 @@
// 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;
// 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 1 'sphere' (equirectangular, uSpan radians of longitude, turned by uYaw),
// 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;
// sampled at this eye's part of the frame across, uUvX, as above);
// (toward you), size uScrSize, bent by uBend radians around a vertical circle);
// 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
@@ -63,7 +59,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, uUvX;
uniform vec2 uScrSize, uTexel;
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;
@@ -97,7 +93,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) * uUvX.x + uUvX.y, pol / PI, step(abs(lon), uSpan * 0.5));
return vec3(lon / uSpan + 0.5, pol / PI, step(abs(lon), uSpan * 0.5));
}
// The cinema screen, in its own axes.
vec3 e = eye - uScrC;
@@ -136,16 +132,13 @@
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, 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;
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;
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
@@ -156,7 +149,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, sb, 6.0).rgb * a;
if (a > 0.002) back += texture2D(uBack, b.xy, 6.0).rgb * a;
}
}
float grain = fract(sin(dot(floor(vUv / uTexel), vec2(12.9898, 78.233))) * 43758.5453) - 0.5;
@@ -186,7 +179,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', uvX: 'uUvX' });
scrC: 'uScrC', scrR: 'uScrR', scrU: 'uScrU', scrN: 'uScrN', voidC: 'uVoid', scrSize: 'uScrSize', texel: 'uTexel', glow: 'uGlow' });
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);
@@ -227,9 +220,6 @@
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);
+14 -50
View File
@@ -40,11 +40,7 @@
// "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, 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>'),
// icon), on (icon filled), disabled }; 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,
@@ -75,11 +71,9 @@
};
// 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 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.
// 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.
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 = {
@@ -97,7 +91,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: '2d_2', flatsbs: '3d_2', '360mono': '360', '360eac': 'view_in_ar', '180mono': 'panorama_photosphere', '180sbs': '3d_rotation' };
const FORMAT_ICONS = { flat: 'theaters', '360mono': '360', '360eac': 'view_in_ar', '180mono': 'panorama_photosphere' };
// 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');
@@ -235,8 +229,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.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);
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);
}
function iconLabel(icon, text, filled = false) {
if (text) { S.draw(g, icon, 0, -13, 36, filled); label(text, 'labelMedium', 23); }
@@ -324,6 +318,9 @@
// 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 = [
@@ -333,15 +330,10 @@
['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', formatIcon(state.format && state.format.id), tr('panel.format')], // (the icon tells the formats apart)
['format', FORMAT_ICONS[state.format && state.format.id] || 'theaters', fmtLabel],
['recenter', 'recenter', tr('panel.recenter')],
];
// 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 width = it => (it[0] === 'play' ? PLAY.w : BTN.w);
const total = items.reduce((s, it) => s + (it ? width(it) + GAP : SEP - GAP), -GAP);
let x = (W - total) / 2;
for (const it of items) {
@@ -353,9 +345,8 @@
} 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
// 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))),
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))),
{ kind: state.menuOpen === id ? 'selected' : 'tonal', disabled: !!(extra && extra.disabled) });
}
x += width(it) + GAP;
@@ -417,16 +408,6 @@
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();
@@ -480,7 +461,6 @@
else {
drawSeekBar(state, shown);
if (queue) drawPlaylistButtons(queue, state);
if (sa) drawSeekAction(sa, saRect, state);
}
drawTransport(state); // the button row
@@ -523,19 +503,6 @@
}
}
// 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
@@ -657,8 +624,5 @@
titleMoving: () => titleMoving, animating: motion.moving };
}
// 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 };
ns.vrPanelUI = { createPanelUI, formatTime: fmt, speedName };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+4 -11
View File
@@ -10,8 +10,7 @@
// 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, icons (optional: Material Symbols names, or
// { name, color } for one in its own colour (symbols.js drawIcon), drawn
// on (the current one: ticked), disabled, icons (optional: Material Symbols names drawn
// on the row's left, e.g. ['4k', '60fps']; the tick then moves to the right end) }],
// note (a grey line above the items), scroll
// (a long list scrolls: see drawList), key, width }
@@ -97,11 +96,7 @@
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));
// 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
g.save(); g.beginPath(); g.rect(list.x, list.y, list.w, list.h); g.clip();
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
@@ -116,17 +111,15 @@
const fg = it.on ? P.onSecondaryContainer : P.onSurface;
g.fillStyle = fg;
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));
(it.icons || []).forEach((name, k) => S.draw(g, name, r.x + ICON_LEFT + ICON_STEP * k + ICON_STEP / 2, r.y + r.h / 2, ICON, false));
const lines = String(it.text).split('\n'), textW = r.w - textX - (lead ? 52 : 12);
g.textAlign = 'left'; g.textBaseline = 'middle';
g.font = T.font('labelLarge', 24);
g.fillText(clip(lines[0], 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;
if (hv > 0.01) rings.push([r, rad, hv]);
hoverRing(g, r.x, r.y, r.w, r.h, 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;
+2 -8
View File
@@ -12,9 +12,7 @@
// 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; 'round': a round picture
// in the middle, e.g. a person or a channel (a letter until it loads); default:
// cropped to fill),
// fit ('contain': the whole picture, letterboxed, e.g. a product; 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) {
@@ -170,11 +168,7 @@
// 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 (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); }
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);
+3 -12
View File
@@ -13,8 +13,6 @@
// 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,
@@ -43,9 +41,7 @@
// '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), 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.
// Also: hitTest(u, v) -> { id } | null, has(id), imagesReady(), perPage, canvas, aspect.
(function (ns) {
'use strict';
@@ -64,7 +60,6 @@
// 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;
@@ -103,7 +98,7 @@
// Name (+ badge) and the stats line beside the picture.
function drawTitle(m) {
const h = m.header;
const maxW = (m.iconAction ? ICON_ACTION.x : ACTION.x) - NAME_X - 24;
const maxW = 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] || '';
@@ -122,9 +117,6 @@
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
@@ -457,12 +449,11 @@
// (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, rectOf, scrollAt, imagesReady: kit.imagesReady, perPage: grid.PER_PAGE, animating: () => motion.moving() || grid.moving(),
canvas, aspect: H / W, draw, hitTest, has, 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)
+1 -29
View File
@@ -4,7 +4,6 @@
//
// 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
@@ -45,12 +44,6 @@
'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'],
@@ -123,9 +116,6 @@
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'],
@@ -141,9 +131,6 @@
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'],
@@ -204,9 +191,6 @@
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'],
@@ -384,17 +368,5 @@
return true;
}
// 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) };
ns.vrSymbols = { draw, has: name => name in PATHS, NAMES: Object.keys(PATHS) };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+6 -42
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, icons?, disabled? }] },
// quality: { current, currentLabel, levels: [{ id, label, icons? }] },
// live: null | { atHead, behind (seconds), dvr } (a live stream; time
// and duration then cover only the rewindable part),
// speed, speeds: [rates], volume: { level, muted },
@@ -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: [...], seek } (the site's own buttons on
// extras: { left: [...], right: [...] } (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,12 +127,6 @@
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, opts) => typeInto(p, on, opts),
});
@@ -180,12 +174,6 @@
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(':');
@@ -238,18 +226,7 @@
// 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];
@@ -663,13 +640,6 @@
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);
@@ -1117,12 +1087,12 @@
let menuState = null;
if (menu === 'quality') {
menuState = {
items: quality.levels.map(q => ({ id: `q:${q.id}`, text: q.label, on: q.id === quality.current, icons: q.icons, disabled: !!q.disabled })),
items: quality.levels.map(q => ({ id: `q:${q.id}`, text: q.label, on: q.id === quality.current, icons: q.icons })),
note: quality.levels.length ? null : tr('panel.noQualities', { site: site.name || tr('panel.theSite') }),
};
} else if (menu === 'format') {
menuState = {
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)] })),
items: format.options.map(o => ({ id: `f:${o.id}`, text: o.short, on: o.id === format.current, disabled: o.disabled })),
note: null,
};
} else if (menu === 'speed') {
@@ -1132,10 +1102,6 @@
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;
@@ -1159,7 +1125,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), seek: extrasNow.seek || null } : null,
extras: extrasNow ? { left: (extrasNow.left || []).map(stripMenu), right: (extrasNow.right || []).map(stripMenu) } : 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) : '',
@@ -1314,9 +1280,7 @@
}
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).
// (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;
const MA = Math.min(1, Math.max(0, anim.menu.get())) * cpA, 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));
+6 -39
View File
@@ -1,14 +1,12 @@
// 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, Channels (the ones you subscribe to: a click opens one in the Channel
// panel), Your channel, History, Playlists, Watch later, Liked videos, Movies &
// in: Subscriptions, 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()), browseChannels() ->
// pager (youtube-subscriptions.js channels()), browseFeed(name) ->
// browseSubscriptions() -> pager (youtube-subscriptions.js feed()), 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
@@ -23,11 +21,8 @@
// 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"). 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.
// "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.
const EXPLORE = [
{ id: 'home', icon: 'home', load: (a, chip) => a.browseHome(chip), chips: 'home' },
{ id: 'shorts', icon: 'smart_display', soon: true },
@@ -42,28 +37,9 @@
{ 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 },
@@ -106,7 +82,6 @@
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;
@@ -119,7 +94,6 @@
// 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);
@@ -139,12 +113,6 @@
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 }); })
@@ -173,8 +141,7 @@
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 && current().pick) current().pick(host, it);
else if (it) actions.openVideo(it.id, it.playlistId || current().list);
if (it) actions.openVideo(it.id, it.playlistId || current().list);
}
}
@@ -190,7 +157,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 => (s.card ? s.card(it) : ns.ytVr.card(it))),
items: items().slice(page * perPage, page * perPage + perPage).map(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,
+4 -65
View File
@@ -1,8 +1,7 @@
// 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), with the notification
// bell beside it once subscribed (a round button opening a menu of YouTube's choices). Three tabs aren't
// has one), and the Subscribe button (unsubscribing asks in a dialog). 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
@@ -17,16 +16,12 @@
// 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 }, setBell(option) -> Promise of { ok, error },
// bellMatches(label, state), openVideo(id, playlistId), state(),
// setSubscribed(id, on) -> Promise of { ok, error }, 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) {
@@ -78,7 +73,6 @@
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++; };
@@ -231,22 +225,18 @@
else if (k.length === 1) { search.text += k; bump(); }
}
// bellHint: the channel's bell from elsewhere (the Channels list's notifications), until
// the header brings its own.
function openChannel(id, subscribedHint, bellHint) {
function openChannel(id, subscribedHint) {
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();
@@ -281,43 +271,8 @@
}, 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) {
@@ -391,9 +346,6 @@
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') }),
@@ -429,20 +381,7 @@
host.open();
}
// 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 };
return { model, event, afterDraw, opened: openCurrent, openChannel, openCurrent, showCurrent, noteSubscribed, channelId: () => channelId };
}
ns.ytVr = ns.ytVr || {};
+4 -18
View File
@@ -1,12 +1,11 @@
// 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), Save (right: a scrolling menu of Watch later and
// VR by the shell, captions-overlay.js) and 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), and Jump ahead (at the seek bar's end: YouTube Premium's jump to where
// most viewers skip to, marked with the Diamond).
// the menu stays open).
//
// const ex = ns.ytVr.extras.create(); one per VR session (provider.js session())
// ex.state(st) -> { left: [captions], right: [save], seek: jump | null } every frame (st: { ad, noVideo, paused, live }: all
// ex.state(st) -> { left: [captions], right: [save] } every frame (st: { ad, noVideo }: both
// 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
@@ -73,17 +72,10 @@
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 },
};
// 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 };
return { left: [cc], right: [sv] };
}
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);
@@ -100,11 +92,5 @@
}
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 || {});
+1 -7
View File
@@ -18,11 +18,7 @@
// 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), 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
// isOpen() (this panel), closeMenu() (the controls panel's open menu), confirm(d) (ask
// in the shell's dialog: d = { title, text, confirm, cancel, danger } -> Promise of bool),
// typeInto(on, opts) (opts.emojis: [{ text, image, label }], the panel's own emojis for the
// keyboard's emoji pages; ask
@@ -43,8 +39,6 @@
// 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';
+2 -26
View File
@@ -63,25 +63,6 @@
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
@@ -340,10 +321,6 @@
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(),
@@ -357,7 +334,6 @@
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
@@ -382,14 +358,14 @@
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: qualityState(q),
quality: { current: q.current, currentLabel: qualityName(q.current, q.labels), icons: qualityIcons(q.current, q.labels), levels: (q.levels || []).map(id => ({ id, label: qualityName(id, q.labels), icons: qualityIcons(id, q.labels) })) },
speed: youtube.speed(), speeds: youtube.speeds(), volume: youtube.volume(), chapters: chapters(), heatmap: l ? [] : heatmap(), ad: ad(),
nowPlaying: nowPlaying({ title, channel, details, playlist: pl, subscribeUi: subscribeUi() }),
// For YouTube's panels:
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(), paused: youtube.isPaused(), live: !!l }),
extras: extras.state({ ad: ad() }),
liveChat: chatFor(l), // a live stream's chat, or a finished one's chat replay (live-chat.js)
};
// No video playing (VR started from the Home page...): what the panels show instead of
@@ -88,11 +88,6 @@
'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',
@@ -78,11 +78,6 @@
'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',
+1 -40
View File
@@ -84,8 +84,7 @@
thumb: bestImage(g.thumbnailRenderer) };
}
// A post (backstagePostRenderer / postRenderer): { id, text, author, channelId (the
// author's, '' if not given), avatar, published, likes,
// A post (backstagePostRenderer / postRenderer): { id, text, author, 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),
@@ -110,7 +109,6 @@
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') || '',
@@ -164,7 +162,6 @@
// 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 }
@@ -207,45 +204,9 @@
// 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];
+3 -6
View File
@@ -180,15 +180,12 @@
}
// 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 from = document.querySelector('ytd-page-manager') || document.querySelector('ytd-app');
if (!from) return false;
const app = document.querySelector('ytd-app');
if (!app) return false;
const endpoint = { commandMetadata: { webCommandMetadata: { url, webPageType: 'WEB_PAGE_TYPE_WATCH', rootVe: 3832 } }, watchEndpoint };
try {
from.dispatchEvent(new CustomEvent('yt-navigate', { bubbles: true, composed: true, detail: { endpoint } }));
app.dispatchEvent(new CustomEvent('yt-navigate', { bubbles: true, composed: true, detail: { endpoint } }));
return true;
} catch (_) { return false; }
}
+11 -64
View File
@@ -55,83 +55,30 @@
// 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 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.
// video; Premium's extra "1080p Premium" entry is skipped in favour of the plain one).
function quality() {
try {
const p = player();
const levels = (p && p.getAvailableQualityLevels && p.getAvailableQualityLevels()) || [];
const current = (p && p.getPlaybackQuality && p.getPlaybackQuality()) || null;
const labels = {}, formats = {};
let premium = null;
const labels = {};
const data = (p && p.getAvailableQualityData && p.getAvailableQualityData()) || [];
for (const d of data) {
if (!d || !d.quality || !d.qualityLabel) continue;
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; }
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 (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 }; }
return { current, levels, labels };
} catch (_) { return { current: null, levels: [], labels: {} }; }
}
// 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.
// 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.
function setQuality(level) {
const p = player();
if (!p) return;
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; }
try { if (p.setPlaybackQualityRange) p.setPlaybackQualityRange(level, level); } catch (_) { /* older player */ }
try { if (p.setPlaybackQuality) p.setPlaybackQuality(level); } catch (_) { /* newer player */ }
}
// Name of the channel that posted the video.
@@ -403,7 +350,7 @@
}
ns.youtube = {
player, video, call, toggle, seek, seekTo, isPaused, currentTime, duration, title, channel, quality, setQuality, hasPremium, jumpAhead, jumpAheadOffered, info,
player, video, call, toggle, seek, seekTo, isPaused, currentTime, duration, title, channel, quality, setQuality, info,
speeds, speed, setSpeed, volume, setVolume, setMuted,
captions, setCaptions, captionLines, audioTracks, setAudioTrack,
loop, setLoop, autoplay, setAutoplay, playlistPosition, nextVideo, previousVideo, playAt,
+10 -78
View File
@@ -126,84 +126,19 @@
return { current, options };
}
// 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) {
// 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() {
const p = D.pager(async token => {
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 }));
}
}
const r = await D.innertube('browse', token ? { continuation: token } : { browseId: 'FEchannels' });
if (!token) p.message = signInMessage(r);
// 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 = 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) };
const items = C.findKeyAll(r, 'channelRenderer').map(fromChannel).filter(Boolean)
.map(c => ({ ...c, subscribed: true }));
return { items, token: C.pageToken(r) };
}, null);
p.message = ''; p.sorts = [];
p.message = '';
return p;
}
@@ -251,9 +186,6 @@
// 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()).
@@ -272,5 +204,5 @@
} catch (err) { return { ok: false, confirmed: false, error: String(err && err.message || err) }; }
}
ns.youtubeSubscriptions = { feed, channels, subscribe, unsubscribe, setNotifications, bellMatches, fromChannel, checkSignIn, checkAccount, answeredSignedIn, signedIn: D.signedIn };
ns.youtubeSubscriptions = { feed, channels, subscribe, unsubscribe, setNotifications, fromChannel, checkSignIn, checkAccount, answeredSignedIn, signedIn: D.signedIn };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+12 -26
View File
@@ -85,9 +85,7 @@
// 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 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).
// the frame's alpha: see draw), and outside it's discarded.
const VS = `
attribute vec2 aCorner; // -0.5..0.5
uniform mat4 uProj, uView, uModel; uniform vec2 uSize; uniform float uBend;
@@ -103,14 +101,14 @@
gl_Position = uProj * uView * uModel * vec4(p, 1.0);
}`;
const FS = `
precision mediump float; uniform sampler2D uTex; uniform vec2 uScreen, uUvX; uniform float uRadius; varying vec2 vUv;
precision mediump float; uniform sampler2D uTex; uniform vec2 uScreen; 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, vec2(vUv.x * uUvX.x + uUvX.y, vUv.y)).rgb, a);
gl_FragColor = vec4(texture2D(uTex, vUv).rgb, a);
}`;
// The glow (WebGL 2 shaders, GLSL ES 3.00, for textureLod): the same strip, bigger (uSize,
@@ -122,8 +120,7 @@
// 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. Read at this eye's
// part of the frame across (uUvX), as FS.
// nothing at uReach metres. Added to what's behind (light), at uStrength.
const GLOW_VS = `#version 300 es
in vec2 aCorner;
uniform mat4 uProj, uView, uModel; uniform vec2 uSize; uniform float uBend;
@@ -140,7 +137,7 @@
}`;
const GLOW_FS = `#version 300 es
precision mediump float;
uniform sampler2D uTex; uniform vec2 uGlow, uScreen, uUvX; uniform float uRadius, uReach, uDetail, uStrength;
uniform sampler2D uTex; uniform vec2 uGlow, uScreen; uniform float uRadius, uReach, uDetail, uStrength;
in vec2 vUv;
out vec4 outColor;
void main() {
@@ -152,7 +149,6 @@
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);
}`;
@@ -205,20 +201,18 @@
return new Float32Array(out);
}
// 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,
// Build the cinema screen renderer. 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, fmt = 'flat') {
const spans = ns.formats.eyeSpans(fmt); // which part of the frame each eye sees (3D side by side: its half)
function createCinemaScreen(gl) {
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', uvX: 'uUvX' });
const U = G.uniforms(gl, prog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', tex: 'uTex', screen: 'uScreen', radius: 'uRadius' });
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', uvX: 'uUvX' });
screen: 'uScreen', radius: 'uRadius', reach: 'uReach', detail: 'uDetail', strength: 'uStrength' });
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]);
@@ -243,12 +237,6 @@
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 }
@@ -334,7 +322,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 = eyeAspect(video.videoWidth / video.videoHeight);
if (video.videoWidth && video.videoHeight) aspect = video.videoWidth / video.videoHeight;
}
// What's behind the panels, for the glass look (vr-panel/panel-pass.js): the video's
@@ -348,7 +336,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, uvX: spans };
return { kind: 'screen', tex, centre: centre(), right: axis(0), up: axis(1), normal: axis(2), size: [width, height()], bend: bend(), glow: g };
}
// The handles: { id, frame (a point on the screen's centre line, see edgeFrame), x, y
@@ -421,7 +409,6 @@
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);
@@ -447,7 +434,6 @@
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);
@@ -581,7 +567,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, fmt) : ns.videoSphere.createVideoSphere(gl, fmt);
return ns.formats.isFlat(fmt) ? createCinemaScreen(gl) : ns.videoSphere.createVideoSphere(gl, fmt);
}
// The global reset: forget the remembered size, placement and curve.
+7 -12
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:
// [1,1,0,0] for the whole frame, or one eye's half of a side-by-side 3D frame
// (formats.eyeSpans).
// 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.
const VS = `
attribute vec3 aPos; attribute vec2 aUv;
uniform mat4 uProj, uView, uModel; uniform vec4 uUvXform;
@@ -97,7 +97,6 @@
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),
@@ -118,14 +117,13 @@
// 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, `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.
// 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.
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), uvX: spans, yaw: lastYaw };
return { kind: formats.projectionMode(fmt) > 0.5 ? 'eac' : 'sphere', tex, span: formats.longitudeSpan(fmt), yaw: lastYaw };
}
// The video is "at infinity": only the head's rotation matters, not its position.
@@ -149,10 +147,7 @@
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);
// 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.uniform4fv(U.xf, [1, 1, 0, 0]); // whole frame for both eyes (YouTube sends one eye)
gl.drawElements(gl.TRIANGLES, mesh.idx.length, gl.UNSIGNED_SHORT, 0);
}
if (vaos) vaos.bind(null);
+8 -17
View File
@@ -2,16 +2,13 @@
// 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).
//
// 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").
// There are no 3D formats: on desktop YouTube sends one eye only, even for 3D videos.
(function (ns) {
'use strict';
// 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']);
// 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']);
const DEFAULT = 'flat'; // when nothing is known about the video
const isFormat = id => IDS.includes(id);
@@ -25,20 +22,14 @@
// 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' || 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] });
const longitudeSpan = fmt => (fmt === '180mono' ? Math.PI : Math.PI * 2);
// 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 (2D, or 3D side by side) go on the cinema screen; everything else on the
// video sphere.
const isFlat = fmt => fmt === 'flat' || fmt === 'flatsbs';
// Flat videos go on the cinema screen; everything else on the video sphere.
const isFlat = fmt => fmt === 'flat';
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, eyeSpans, isSbs, projectionMode, isFlat };
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, projectionMode, isFlat };
})(globalThis.__streamxr = globalThis.__streamxr || {});
-4
View File
@@ -15,17 +15,13 @@
// 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,17 +7,13 @@
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)',
+8 -17
View File
@@ -3,16 +3,13 @@
// 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).
//
// 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").
// There are no 3D formats: on desktop YouTube sends one eye only, even for 3D videos.
(function (ns) {
'use strict';
// 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']);
// 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']);
const DEFAULT = 'flat'; // when nothing is known about the video
const isFormat = id => IDS.includes(id);
@@ -26,20 +23,14 @@
// 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' || 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] });
const longitudeSpan = fmt => (fmt === '180mono' ? Math.PI : Math.PI * 2);
// 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 (2D, or 3D side by side) go on the cinema screen; everything else on the
// video sphere.
const isFlat = fmt => fmt === 'flat' || fmt === 'flatsbs';
// Flat videos go on the cinema screen; everything else on the video sphere.
const isFlat = fmt => fmt === 'flat';
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, eyeSpans, isSbs, projectionMode, isFlat };
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, projectionMode, isFlat };
})(globalThis.__streamxr = globalThis.__streamxr || {});
-4
View File
@@ -16,17 +16,13 @@
// 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,17 +8,13 @@
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)',