19 Commits
Author SHA1 Message Date
Fauxx cf33215a60 Release v0.101.7 (#9)
Release v0.101.7: everything since v0.93.3 (0.93.4 to 0.101.7), merged
into `develop` from `Feature-3-Add-To-Queue`. Tested in the headset on
the Steam Frame.

## Highlights
- 🧭 **Explore's new tabs:** Live, Gaming, Music, Hype, Courses, Podcasts
and Memberships, with sections as pills and square podcast cards.
- ⭐ **Channel memberships:** Members only flags, membership offers on
members-only videos, the Membership tab, and the Channel panel's
Podcasts tab.
- 📃 **Playlist pages:** see a playlist before playing it (Play all,
Shuffle, Save, its videos) from any panel.
- ℹ️ **Info cards** and an **Autoplay** button on the controls panel.
- 🔚 **End screens** in Up Next, with the autoplay countdown.
- 🧲 **Follow your head** for regular videos (Settings → 2D Cinema), with
Delay and Speed sliders.
- 📋 **Add to queue** from any video card, with a Queue tab in Up Next
that you can **Clear** or **Save** as a playlist.
- 🩹 **Fixes:** no more seams on 360° cubemap videos, the Channel panel's
Subscribe state, shuffle, and many smaller ones.

Thanks to [u/boatOV](https://www.reddit.com/user/boatOV),
[u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)
and [@MinuteHistoryX](https://x.com/MinuteHistoryX) for the request and
reports.

## Checklist
- [x] `manifest.json` version is 0.101.7
- [x] CHANGELOG entry for 0.101.7 (the release after v0.93.3)
- [x] README "What's new in v0.101.7" and its "Jump to" link
- [x] No new permissions, hosts or libraries

<!-- 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`: 13 commits, 49
files (+3064 −357).

- ⚠️ No linked issue: add `Closes #<issue>` under "What and why" (an
agreed issue is required).
- ✅ Version: 0.93.3 → 0.101.7
- ✅ `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/options.js`,
`src/core/page/vr-panel/styles/queue.js`,
`src/streamxr/page/xr-session.js` (fine in comments giving an example;
otherwise move it to the provider).

#### Commits (13)

| Commit | Message | Author |
|---|---|---|
|
[`c45ec45`](https://github.com/FauxxYT/StreamXR/commit/c45ec45ea6aa7241041e44743b5376994e352d09)
| Give proper credit in the `README` | @FauxxYT |
|
[`e23c1bf`](https://github.com/FauxxYT/StreamXR/commit/e23c1bf40f086ef170277d469c3934a6e1299dab)
| Fix the seams showing up while Glass mode in On in 360 Cubemap Videos
| @FauxxYT |
|
[`e15cfad`](https://github.com/FauxxYT/StreamXR/commit/e15cfade1621065d52edb5eeaf1494a3cab58273)
| Movies in Recommended are deferred to later as all movies on YouTube
(even the free ones) have DRM. Will add support for this back in when
Movies can play on Desktop XR without DRM | @FauxxYT |
|
[`ac75bf2`](https://github.com/FauxxYT/StreamXR/commit/ac75bf28837993d1c0970ed88c83af1f7ddeddbb)
| Explore tabs (Live, Gaming, Music, News, Podcasts, Memberships,
Courses) with View all and square podcast cards; members-only videos
(poster, greyed Play, Join dialog, Membership tab); member
perks/videos/posts with Members only flags; Channel Podcasts tab; fix
Channel panel subscribed state | @FauxxYT |
|
[`7dfa99e`](https://github.com/FauxxYT/StreamXR/commit/7dfa99e8f1c507c5d527516015e7595209e12af0)
| Playlist pages: open playlists from cards in Explore, You, Search,
Channel and Up Next (details, Play all, Shuffle, Save, video rows); Up
Next's playlist tab as a page; shuffle card on Next; Save synced across
panels | @FauxxYT |
|
[`ba21ef9`](https://github.com/FauxxYT/StreamXR/commit/ba21ef957ac263ace2a7bc767dde993ad72588f6)
| Finish the Shelves in the Gaming page | @FauxxYT |
|
[`cbffae1`](https://github.com/FauxxYT/StreamXR/commit/cbffae17e7f6c2e6e0d7d449955cd4272f0aa80e)
| Add Info cards support and buttons to the Control Panel | @FauxxYT |
|
[`9176971`](https://github.com/FauxxYT/StreamXR/commit/91769713090704c82942ac4b61aab2efaad5c73a)
| Add the YouTube Video End Screen | @FauxxYT |
|
[`36ef0d9`](https://github.com/FauxxYT/StreamXR/commit/36ef0d991fc0fd208441039bd36d23ddd9ef7021)
| Added new setting to allow 2D videos to follow user's head | @FauxxYT
|
|
[`1e1af79`](https://github.com/FauxxYT/StreamXR/commit/1e1af79ecf76fa9e2c4bdd1427046fb2a764f8e9)
| Add `Add to Queue` feature and prep docs for release | @FauxxYT |
|
[`c8fcc6b`](https://github.com/FauxxYT/StreamXR/commit/c8fcc6be5ddfaf4c1f0e80df5ba09370002ac028)
| Add `Save to Playlist` and `Clear` to the `Queue` | @FauxxYT |
|
[`19e04a3`](https://github.com/FauxxYT/StreamXR/commit/19e04a3bc44718f29ae10732343d91863da8a882)
| Fix bug in Queue and update docs for rollout | @FauxxYT |
|
[`649c689`](https://github.com/FauxxYT/StreamXR/commit/649c689a0336fb0ce0cfb1293510b6996cea4b34)
| v0.101.7: Explore tabs, memberships, playlist pages, info cards, end
screen, Follow your head and Add to queue (#8) | @FauxxYT |

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

- ✏️ `ARCHITECTURE.md` +10 −5
- ✏️ `CHANGELOG.md` +456 −3
- ✏️ `README.md` +47 −30
- ✏️ `docs/Spatial-Layout.md` +2 −2
- ✏️ `docs/design-choices.md` +64 −0
- ✏️ `docs/features/channel-page.md` +9 −3
- ✏️ `docs/features/home-page.md` +18 −1
- ✏️ `docs/features/video-page.md` +20 −15
- ✏️ `docs/whats-left.md` +52 −12
- ✏️ `docs/youtube.md` +171 −0
- ✏️ `manifest.json` +3 −1
- ✏️ `src/core/page/vr-panel/panel-ui.js` +4 −3
- ✏️ `src/core/page/vr-panel/popups-ui.js` +54 −15
- ✏️ `src/core/page/vr-panel/safe-zone.js` +6 −6
- ✏️ `src/core/page/vr-panel/styles/catalog.js` +54 −4
- 🆕 `src/core/page/vr-panel/styles/collection.js` +149 −0
- ✏️ `src/core/page/vr-panel/styles/grid.js` +14 −5
- ✏️ `src/core/page/vr-panel/styles/kit.js` +46 −11
- ✏️ `src/core/page/vr-panel/styles/options.js` +46 −15
- ✏️ `src/core/page/vr-panel/styles/profile.js` +93 −3
- ✏️ `src/core/page/vr-panel/styles/queue.js` +57 −8
- ✏️ `src/core/page/vr-panel/symbols.js` +15 −0
- ✏️ `src/core/page/vr-panel/vr-panel.js` +107 −27
- ✏️ `src/sites/youtube/page/provider/browse.js` +108 −24
- ✏️ `src/sites/youtube/page/provider/cards.js` +26 −5
- ✏️ `src/sites/youtube/page/provider/channel.js` +158 −19
- ✏️ `src/sites/youtube/page/provider/extras.js` +73 −6
- ✏️ `src/sites/youtube/page/provider/panels.js` +7 −3
- 🆕 `src/sites/youtube/page/provider/playlist.js` +159 −0
- ✏️ `src/sites/youtube/page/provider/provider.js` +132 −16
- ✏️ `src/sites/youtube/page/provider/search.js` +12 −5
- ✏️ `src/sites/youtube/page/provider/settings.js` +4 −1
- ✏️ `src/sites/youtube/page/provider/strings-en.js` +41 −0
- ✏️ `src/sites/youtube/page/provider/strings-es.js` +41 −0
- ✏️ `src/sites/youtube/page/provider/upnext.js` +175 −41
- ✏️ `src/sites/youtube/page/youtube-channel.js` +78 −5
- ✏️ `src/sites/youtube/page/youtube-content.js` +130 −23
- ✏️ `src/sites/youtube/page/youtube-data.js` +63 −3
- ✏️ `src/sites/youtube/page/youtube-home.js` +62 −9
- ✏️ `src/sites/youtube/page/youtube-page.js` +69 −6
- ✏️ `src/sites/youtube/page/youtube-player.js` +34 −1
- ✏️ `src/streamxr/page/cinema-options.js` +29 −8
- ✏️ `src/streamxr/page/cinema-screen.js` +35 −5
- ✏️ `src/streamxr/page/video-sphere.js` +6 −2
- ✏️ `src/streamxr/page/xr-session.js` +93 −6
- ✏️ `src/streamxr/shared/strings-en.js` +8 −0
- ✏️ `src/streamxr/shared/strings-es.js` +8 −0
- ✏️ `src/streamxr/ui/shared/strings-en.js` +8 −0
- ✏️ `src/streamxr/ui/shared/strings-es.js` +8 −0

</details>
<!-- pr-summary:end -->
2026-10-22 22:15:02 -07:00
Fauxx 649c689a03 v0.101.7: Explore tabs, memberships, playlist pages, info cards, end screen, Follow your head and Add to queue (#8)
Everything since v0.93.3 (0.93.4 to 0.101.7), tested in the headset on
the Steam Frame. Details per version are in CHANGELOG.md.

## Added
- **Explore's new tabs:** Live, Gaming, Music, Hype, Courses, Podcasts
and Memberships, with sections as pills (Gaming's game shelves too,
signed in), View all, and square podcast cards.
- **Channel memberships:** a Members only flag on videos and posts; a
video YouTube won't play shows its thumbnail with Play greyed out; a
members-only video offers the channel's membership; the Membership tab
(your level, perks, members-only videos and posts when you're a member);
the Channel panel's Podcasts tab, and the panel follows the playing
video's channel.
- **Playlist pages:** clicking a playlist in Explore, You, Search,
Channel or Up Next opens its page (details, Play all, Shuffle, Save, its
videos); Up Next's playlist tab is the same page; a shuffle card on Next
while shuffle is on.
- **Info cards:** a Cards button on the controls panel and a clickable
teaser.
- **Autoplay:** YouTube's switch as a controls panel button (the panel
is now 1408 px wide).
- **End screen:** its tiles in a new Up Next tab (which opens Up Next
and brings hidden panels back), plus the autoplay countdown as a card.
- **Follow your head** (Settings → 2D Cinema, off by default): regular
videos and their panels lazily follow your head left/right and up/down,
with Follow delay and Follow speed sliders. Settings tabs now scroll.
- **Add to queue:** a Queue button on cards and a Queue tab in Up Next.
Clear asks first; Save opens a dropdown with New playlist (named on the
VR keyboard), Watch later and your playlists.
- **Shell:** `host.prompt()` (a dialog with a text field),
`host.openMenu(control, { below: true })` (dropdown menus),
`host.reveal()`, and menu rows with images.

## Fixed
- Seams on 360° cubemap videos with the glass look on.
- Explore: Mixes without thumbnails, Memberships' creators missing when
signed in, and "load more" stopping early.
- The members-only dialog coming up on normal videos.
- The Channel panel saying "Subscribe" on channels you're subscribed to.
- Shuffle only shuffling the first video, and Save showing different
states in two panels.
- Info cards read from the wrong data, and the teaser coming up with the
panels hidden.
- The end screen staying on into the next video.
- The Follow your head switch never showing as on.
- Queue: pointing rings cut off; the Save list's HTTP 400 error; a
cleared queue staying in Up Next; the playing mark jumping to the next
video after adding.

## Thanks
- [u/boatOV](https://www.reddit.com/user/boatOV) for asking for Follow
your head.
- [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)
and [@MinuteHistoryX](https://x.com/MinuteHistoryX) for reporting the
360° seams.

## Notes
- No new permissions or hosts; no libraries.
- Docs updated: README (What's new in v0.101.7, Roadmap), CHANGELOG,
docs/whats-left.md, docs/features/, docs/youtube.md,
docs/design-choices.md.

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

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

**FauxxYT** wants to merge `FauxxYT:Feature-3-Add-To-Queue` into
`develop`: 12 commits, 49 files (+3064 −357).

- ⚠️ No linked issue: add `Closes #<issue>` under "What and why" (an
agreed issue is required).
- ✅ Version: 0.93.3 → 0.101.7
- ✅ `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/options.js`,
`src/core/page/vr-panel/styles/queue.js`,
`src/streamxr/page/xr-session.js` (fine in comments giving an example;
otherwise move it to the provider).

#### Commits (12)

| Commit | Message | Author |
|---|---|---|
|
[`c45ec45`](https://github.com/FauxxYT/StreamXR/commit/c45ec45ea6aa7241041e44743b5376994e352d09)
| Give proper credit in the `README` | @FauxxYT |
|
[`e23c1bf`](https://github.com/FauxxYT/StreamXR/commit/e23c1bf40f086ef170277d469c3934a6e1299dab)
| Fix the seams showing up while Glass mode in On in 360 Cubemap Videos
| @FauxxYT |
|
[`e15cfad`](https://github.com/FauxxYT/StreamXR/commit/e15cfade1621065d52edb5eeaf1494a3cab58273)
| Movies in Recommended are deferred to later as all movies on YouTube
(even the free ones) have DRM. Will add support for this back in when
Movies can play on Desktop XR without DRM | @FauxxYT |
|
[`ac75bf2`](https://github.com/FauxxYT/StreamXR/commit/ac75bf28837993d1c0970ed88c83af1f7ddeddbb)
| Explore tabs (Live, Gaming, Music, News, Podcasts, Memberships,
Courses) with View all and square podcast cards; members-only videos
(poster, greyed Play, Join dialog, Membership tab); member
perks/videos/posts with Members only flags; Channel Podcasts tab; fix
Channel panel subscribed state | @FauxxYT |
|
[`7dfa99e`](https://github.com/FauxxYT/StreamXR/commit/7dfa99e8f1c507c5d527516015e7595209e12af0)
| Playlist pages: open playlists from cards in Explore, You, Search,
Channel and Up Next (details, Play all, Shuffle, Save, video rows); Up
Next's playlist tab as a page; shuffle card on Next; Save synced across
panels | @FauxxYT |
|
[`ba21ef9`](https://github.com/FauxxYT/StreamXR/commit/ba21ef957ac263ace2a7bc767dde993ad72588f6)
| Finish the Shelves in the Gaming page | @FauxxYT |
|
[`cbffae1`](https://github.com/FauxxYT/StreamXR/commit/cbffae17e7f6c2e6e0d7d449955cd4272f0aa80e)
| Add Info cards support and buttons to the Control Panel | @FauxxYT |
|
[`9176971`](https://github.com/FauxxYT/StreamXR/commit/91769713090704c82942ac4b61aab2efaad5c73a)
| Add the YouTube Video End Screen | @FauxxYT |
|
[`36ef0d9`](https://github.com/FauxxYT/StreamXR/commit/36ef0d991fc0fd208441039bd36d23ddd9ef7021)
| Added new setting to allow 2D videos to follow user's head | @FauxxYT
|
|
[`1e1af79`](https://github.com/FauxxYT/StreamXR/commit/1e1af79ecf76fa9e2c4bdd1427046fb2a764f8e9)
| Add `Add to Queue` feature and prep docs for release | @FauxxYT |
|
[`c8fcc6b`](https://github.com/FauxxYT/StreamXR/commit/c8fcc6be5ddfaf4c1f0e80df5ba09370002ac028)
| Add `Save to Playlist` and `Clear` to the `Queue` | @FauxxYT |
|
[`19e04a3`](https://github.com/FauxxYT/StreamXR/commit/19e04a3bc44718f29ae10732343d91863da8a882)
| Fix bug in Queue and update docs for rollout | @FauxxYT |

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

- ✏️ `ARCHITECTURE.md` +10 −5
- ✏️ `CHANGELOG.md` +456 −3
- ✏️ `README.md` +47 −30
- ✏️ `docs/Spatial-Layout.md` +2 −2
- ✏️ `docs/design-choices.md` +64 −0
- ✏️ `docs/features/channel-page.md` +9 −3
- ✏️ `docs/features/home-page.md` +18 −1
- ✏️ `docs/features/video-page.md` +20 −15
- ✏️ `docs/whats-left.md` +52 −12
- ✏️ `docs/youtube.md` +171 −0
- ✏️ `manifest.json` +3 −1
- ✏️ `src/core/page/vr-panel/panel-ui.js` +4 −3
- ✏️ `src/core/page/vr-panel/popups-ui.js` +54 −15
- ✏️ `src/core/page/vr-panel/safe-zone.js` +6 −6
- ✏️ `src/core/page/vr-panel/styles/catalog.js` +54 −4
- 🆕 `src/core/page/vr-panel/styles/collection.js` +149 −0
- ✏️ `src/core/page/vr-panel/styles/grid.js` +14 −5
- ✏️ `src/core/page/vr-panel/styles/kit.js` +46 −11
- ✏️ `src/core/page/vr-panel/styles/options.js` +46 −15
- ✏️ `src/core/page/vr-panel/styles/profile.js` +93 −3
- ✏️ `src/core/page/vr-panel/styles/queue.js` +57 −8
- ✏️ `src/core/page/vr-panel/symbols.js` +15 −0
- ✏️ `src/core/page/vr-panel/vr-panel.js` +107 −27
- ✏️ `src/sites/youtube/page/provider/browse.js` +108 −24
- ✏️ `src/sites/youtube/page/provider/cards.js` +26 −5
- ✏️ `src/sites/youtube/page/provider/channel.js` +158 −19
- ✏️ `src/sites/youtube/page/provider/extras.js` +73 −6
- ✏️ `src/sites/youtube/page/provider/panels.js` +7 −3
- 🆕 `src/sites/youtube/page/provider/playlist.js` +159 −0
- ✏️ `src/sites/youtube/page/provider/provider.js` +132 −16
- ✏️ `src/sites/youtube/page/provider/search.js` +12 −5
- ✏️ `src/sites/youtube/page/provider/settings.js` +4 −1
- ✏️ `src/sites/youtube/page/provider/strings-en.js` +41 −0
- ✏️ `src/sites/youtube/page/provider/strings-es.js` +41 −0
- ✏️ `src/sites/youtube/page/provider/upnext.js` +175 −41
- ✏️ `src/sites/youtube/page/youtube-channel.js` +78 −5
- ✏️ `src/sites/youtube/page/youtube-content.js` +130 −23
- ✏️ `src/sites/youtube/page/youtube-data.js` +63 −3
- ✏️ `src/sites/youtube/page/youtube-home.js` +62 −9
- ✏️ `src/sites/youtube/page/youtube-page.js` +69 −6
- ✏️ `src/sites/youtube/page/youtube-player.js` +34 −1
- ✏️ `src/streamxr/page/cinema-options.js` +29 −8
- ✏️ `src/streamxr/page/cinema-screen.js` +35 −5
- ✏️ `src/streamxr/page/video-sphere.js` +6 −2
- ✏️ `src/streamxr/page/xr-session.js` +93 −6
- ✏️ `src/streamxr/shared/strings-en.js` +8 −0
- ✏️ `src/streamxr/shared/strings-es.js` +8 −0
- ✏️ `src/streamxr/ui/shared/strings-en.js` +8 −0
- ✏️ `src/streamxr/ui/shared/strings-es.js` +8 −0

</details>
<!-- pr-summary:end -->
2026-10-22 22:12:46 -07:00
Fauxx 19e04a3bc4 Fix bug in Queue and update docs for rollout 2026-10-22 20:17:40 -07:00
Fauxx c8fcc6be5d Add Save to Playlist and Clear to the Queue 2026-10-20 23:48:15 -07:00
Fauxx 1e1af79ecf Add Add to Queue feature and prep docs for release 2026-10-19 23:42:28 -07:00
Fauxx 36ef0d991f Added new setting to allow 2D videos to follow user's head 2026-10-19 23:04:48 -07:00
Fauxx 9176971309 Add the YouTube Video End Screen 2026-10-19 22:29:21 -07:00
Fauxx cbffae17e7 Add Info cards support and buttons to the Control Panel 2026-10-19 16:23:07 -07:00
Fauxx ba21ef957a Finish the Shelves in the Gaming page 2026-10-19 13:28:04 -07:00
Fauxx 7dfa99e8f1 Playlist pages: open playlists from cards in Explore, You, Search, Channel and Up Next (details, Play all, Shuffle, Save, video rows); Up Next's playlist tab as a page; shuffle card on Next; Save synced across panels 2026-10-18 23:33:40 -07:00
Fauxx ac75bf2883 Explore tabs (Live, Gaming, Music, News, Podcasts, Memberships, Courses) with View all and square podcast cards; members-only videos (poster, greyed Play, Join dialog, Membership tab); member perks/videos/posts with Members only flags; Channel Podcasts tab; fix Channel panel subscribed state 2026-10-18 22:37:08 -07:00
Fauxx e15cfade16 Movies in Recommended are deferred to later as all movies on YouTube (even the free ones) have DRM. Will add support for this back in when Movies can play on Desktop XR without DRM 2026-10-18 16:59:54 -07:00
Fauxx e23c1bf40f Fix the seams showing up while Glass mode in On in 360 Cubemap Videos 2026-10-18 16:52:18 -07:00
Fauxx c45ec45ea6 Give proper credit in the README 2026-10-17 22:47:58 -07:00
Fauxx fa8441352c Release v0.93.3: 3D side by side, YouTube Premium, your Channels and the notification bell (#6)
## What and why

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

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

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

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

## How

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

No new permissions.

## Tested on

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

## Checklist

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

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

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

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

**FauxxYT** wants to merge `FauxxYT:develop` into `main`: 4 commits, 38
files (+1051 −193).

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

#### Commits (4)

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

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

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

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

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

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

## How

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

## Tested on

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

## Checklist

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

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

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

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

**FauxxYT** wants to merge `FauxxYT:Feature-2-Premium-and-Subscrptions`
into `develop`: 3 commits, 38 files (+1051 −193).

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

#### Commits (3)

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

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

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

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

No files matched your search

+4 -1
View File
@@ -18,6 +18,9 @@ for the code rules.
## Never
- Add a library, a package manager, a build step or ES modules (plain scripts only).
- Add a permission or a host to `manifest.json` without the maintainer agreeing to it first.
- Bypass the site's own player (fetching its streams yourself, posing as another client) to
get 3D, resolutions or anything else: StreamXR draws what the site's player plays. Point
such requests to [docs/decisions/0002](docs/decisions/0002-site-player-only.md) and its risks.
- Name a site (YouTube or any other) in `src/core/` or `src/streamxr/`: site code goes behind
its provider in `src/sites/<site>/`.
- Write on-screen words in the code: they go in the string tables (see "Where things go").
@@ -115,7 +118,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 and Save buttons are `extras.js`.
registered in `panels.js`; the controls panel's Captions, Jump ahead and Save buttons are `extras.js`.
- New YouTube behavior goes in the provider or a panel controller; a style or the shell
never gets a YouTube field (add a generic option or slot instead).
- Tunables: `vr-panel/config.js`, `controllers/config.js`, `SETTINGS` in `cinema-screen.js`,
+19 -7
View File
@@ -104,6 +104,7 @@ src/core/ THE CORE (shell, part 1): the VR interface, site-
kit.js generic card, images, badge registry, the M3 tabs (drawTabs, headerTabs), the M3 button, the hover ring, regionAt (hit tests; small controls reach out to 56 dp), createStrip (a row that slides sideways when the laser nears its end)
grid.js tabs, pills, text field, card grid, paging (Catalog + Profile)
thread.js a threaded comment list (Info's Comments, Profile's opened post)
collection.js a collection page (a playlist opened from its card: picture, details, buttons, rows; Catalog + Profile)
chat.js a live chat list (Info's 'chat' content): rows with inline pictures, cards, a pinned banner
info.js / catalog.js / queue.js / profile.js / options.js
orbiters-ui.js the side toolbars (upright M3E floating toolbars on the Safe Zone line), the pill (Settings) and the search bar; a side toolbar has a grab bar on its video side (moves its whole side)
@@ -156,12 +157,13 @@ 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 (the shell's "extras")
extras.js the controls panel's Captions and Save buttons and Jump ahead at the seek bar's end (the shell's "extras": left, right, seek), premiumMark() (the Diamond)
strings-en.js the YouTube panels' words in English (strings-<lang>.js: other languages)
cards.js, badges.js YouTube items -> generic cards; the ✓ / ♪ badges
upnext.js, browse.js (Explore and You), search.js, channel.js, description.js, comments-panel.js, settings.js, live-chat.js
one controller per panel (comments.js: the comment threads, used by
the Comments panel and a Channel post)
the Comments panel and a Channel post; playlist.js: a playlist opened
from its card, used by Explore, You, Search and the Channel panel)
ui/
site.js the site's name and brand colour for the popup (until the palette arrives)
player-button.js, button.css the VR button in YouTube's player controls
@@ -234,7 +236,10 @@ is the source of truth. In short:
`nextVideo`), plus whatever the site's own panel controllers use.
- `state()`, polled every frame: `title`, `nowPlaying`, `time`, `duration`, `live`,
`paused`, `quality` (`{ current, currentLabel, levels: [{ id, label }] }`), `speed`,
`speeds`, `volume`, `chapters`, `ad`, plus what the site's panels read.
`speeds`, `volume`, `chapters`, `ad`, `playable` (false: the site won't play this video;
Play greys out), plus what the site's panels read.
- `poster()` (optional): while the site won't play the video, picture URLs (best first) the
screen shows in its place (`xr-session.js` `posterCanvas`).
- `ad()` (optional): the ad playing now, or null.
- `site`: `{ name, panels (the registry), onControl(id, panel) }`.
- Optional features simply aren't provided: ads, live streams, the storyboard preview,
@@ -286,11 +291,18 @@ 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(), confirm(d), typeInto(on, opts) }`
- `host`: `{ actions, style, perPage, open(), close(), isOpen(), closeMenu(), openMenu(control), panel(id), confirm(d), typeInto(on, opts) }`
(`opts.emojis`: the panel's own emojis, `[{ text, image, label }]`, first on the keyboard's emoji pages);
`confirm(d)` asks in the shell's dialog (`{ title, text (up to four lines), confirm, cancel
(null: no cancel button, a dialog that only informs), danger }` -> a Promise
of true / false); the site's `onControl(id, panel, shell)` gets the same as `shell.confirm`.
- Menus on side panels: `host.openMenu(control)` (e.g. `'action:bell'`) opens a floating menu
above that control, 32 dp in front, like the controls panel's (the style needs `rectOf(id)`;
Profile has it). While it's up, `vr-panel.js` asks the controller's `menu(control)` each frame
(`{ items: [{ id, text, on, disabled, icons }], note, stay }`, `null` closes it); a choice
arrives as `{ type: 'menu', value: item id, menu: control }`. The menu's id is
`'p:<panel>|<control>'` and its items `'pi:<index>'` (`panelMenu`). `host.panel(id)` is another
panel's controller (the You panel opens a channel in the Channel panel with it).
- Typing: a panel with a text field calls `host.typeInto(true)` to get the keys (and
`false` to give them back). One panel types at a time: when another asks, the one that had
the keys is sent `Escape` (as for a click on nothing) and should stop typing. While it's shown, each key reaches its controller as
@@ -317,9 +329,9 @@ control's id there is `<panel id>|<style id>`.
| Style | YouTube panel | Draws |
|---|---|---|
| **Info** (`info.js`) | Description, Comments, Live chat | header tabs; each tab one content kind: `text` (rich text, links), `paged` (rows in two columns: chapters), `rows` (scrolling, current row followed: transcript), `thread` (pills, count, rows with children and "show more": comments), `chat` (chat.js: a pinned banner, messages with inline pictures and coloured cards, following the newest or paused with a "jump to newest" button; optionally a message field with Send, reaction buttons and a Support button, an XP pill and a ranked Top fans list: live chat), `message` (optional loading indicator) |
| **Catalog** (`catalog.js`) | Explore, You, Search | title, source tabs, filter pills (tabs and left-aligned pills slide when they don't fit: kit.js createStrip) or a note or a text field with its button (and a suggestions list dropping down from it), card grid, paging; trims its height to its cards |
| **Queue** (`queue.js`) | Up Next | title or tabs, paging in the header, 3 x 2 cards, a "current" card |
| **Profile** (`profile.js`) | Channel | banner, picture, name + badge, stats line, one action button (looks: primary / secondary / danger / busy), 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) |
| **Catalog** (`catalog.js`) | Explore, You, Search | title, source tabs, filter pills (tabs and left-aligned pills slide when they don't fit: kit.js createStrip) or a note or a text field with its button (and a suggestions list dropping down from it), card grid, paging, or a `collection` page under a back button (collection.js: an opened playlist, scrolling); trims its height to its cards |
| **Queue** (`queue.js`) | Up Next | title or tabs, paging in the header, 3 x 2 cards, a "current" card, or a `collection` page (collection.js: the playing playlist, or one opened from a card under a back button; scrolling) |
| **Profile** (`profile.js`) | Channel | banner, picture, name + badge, stats line, one action button (looks: primary / secondary / danger / busy) and an optional round icon button beside it (the bell), tabs (shrinking to fit beside the pills), pills, a search button (searching: a back button and a text field in place of the tabs), card grid, paging, or one featured card, or a scrolling body: a text `page` (paragraphs, then label / value facts), a `tiers` page (plans or levels to join: title, price, button, items with small pictures, a note), a `feed` of posts (picture, author, text, pictures, a card, a poll with result bars, buttons with icons and counts), or one opened `post` with a back button and its comments (thread.js), or a `collection` page with a back button (collection.js) |
| **Options** (`options.js`) | Settings | tabs (with icons); up to 3 rows (name with its hint under it; at the right: a switch, a slider with a Default button and end captions, a ‹ value › stepper, or a button; faded) or a 4 x 4 choice grid with "More" paging |
Each style file's header lists its model and control ids. Every style returns
+600 -1
View File
@@ -6,6 +6,605 @@ 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.101.7
The release after v0.93.3: everything from 0.93.4 to here. The README's "What's new" describes
it (now with the queue's Clear and Save).
### Fixed
- **Adding to the queue sometimes moved Up Next's "playing" mark to the next video** (and the
Queue tab's count with it): the player's position in the list can be one ahead for a moment;
the playing video is now found by the video the tab is on.
## 0.101.6
### Fixed
- **Clearing the queue left it in Up Next until another video started** (YouTube's own page
cleared it): the watch page keeps the cleared queue in its data until the next video, and
that copy is now ignored too.
## 0.101.5
### Changed
- **The queue's Save menu drops down under its button** (as Material menus do) instead of
opening in the middle of the view: centred on Save, inside the Up Next panel's width and
clear of the side toolbar over the panel's edge. The shell: `host.openMenu(control,
{ below: true })` replaces `{ centred: true }`.
## 0.101.4
### Changed
- **The queue's Save and Clear buttons fill in YouTube's red** while their menu or dialogs are
up, so you can see which one you're in.
- **The queue's Save menu opens in the middle of the view** (where a dialog goes), not up at
its button. The shell: `host.openMenu(control, { centred: true })`.
### Fixed
- **The pointing ring round a scrolling menu's first and last rows was cut off.**
## 0.101.3
### Fixed
- **The queue's Save menu said "Couldn't save (… HTTP 400)"** instead of listing your
playlists: YouTube refuses that list asked for several videos at once; it's now asked for
the queue itself (its list id).
## 0.101.2
### Fixed
- **Clearing the queue left an empty Queue tab** (its rows blank) until another video played:
YouTube's page and player keep the cleared queue a while, so a queue cleared from VR is now
forgotten at once, and its tab goes.
- **After making a new playlist, the Save menu stayed open**: a short dialog now says it's
made (OK closes it).
## 0.101.1
### Added
- **Clear the queue**: a **Clear** button on Up Next's Queue tab; a dialog asks first, then
YouTube's own Clear runs (as its Queue box's button does).
- **Save the queue**: a **Save** button on the Queue tab opens a menu: **New playlist…** (name
it on the VR keyboard; a private playlist of the queued videos), then Watch later and your
playlists (ticked when one already holds the whole queue; a choice adds every queued video).
- The shell: `host.prompt()`, a dialog with a text field typed into on the VR keyboard (Enter
creates, Escape cancels); the Queue style's `rectOf` (menus opening from its page's buttons);
the `clear_all` icon.
## 0.101.0
Release prep after v0.93.3 (everything from 0.93.4 to 0.100.1 below), with no code changes of
its own; the queue's Clear and Save followed, and the release is 0.101.7.
### Changed
- The README's "What's new" now describes this release; its feature list, roadmap and known
issues are brought up to date.
## 0.100.1
### Fixed
- **Adding to the queue jumped Up Next to the Queue tab**: the tab now just appears (a newly
started playlist still opens on its tab).
- **The pointing ring round a playlist's (or the queue's) rows was cut off at the sides**: the
rows are a little narrower, so the ring fits inside the page.
## 0.100.0
### Added
- **Add to queue**: pointing at a video card (in Explore, You, Search, the Channel panel and
Up Next) shows a round **Add to queue** button on its thumbnail's bottom-left corner;
clicking it adds the video to YouTube's own queue (a check shows it's queued), clicking the
rest of the card still plays it. Shorts, playlists and live streams have none.
- **The queue in Up Next**: YouTube's queue gets its own **Queue · 1 / 4** tab, the same page
as a playlist (the playing video marked, a row plays it). As on YouTube, the queued videos
play after the current one.
- The shell: a card can have a `quick` button (`styles/kit.js`); the `add_to_queue` icon.
## 0.99.2
### Added
- **Follow your head follows up and down too**: looking more than 25° up or down for the
delay turns the screen and the panels up or down with you (a recenter levels them again).
- **Follow delay and Follow speed sliders** (Settings → 2D Cinema, each with Default): the
defaults are now a shorter delay (0.6 s, was 1 s) and a gentler speed.
- **Settings tabs scroll**: a tab with more rows than fit (2D Cinema now has nine) scrolls,
with the stick or by dragging, with a bar on the right; each tab keeps its place.
### Fixed
- **Follow your head crept after you while you read a side panel without pointing at it**:
looking at a panel now counts as using it, so nothing follows.
## 0.99.1
### Fixed
- **The Follow your head switch never showed as on**: the setting was saved, but the switch
wasn't told, so it always drew as off (and each click saved "on" again).
## 0.99.0
### Added
- **Follow your head** (Settings → 2D Cinema; off by default; requested by [u/boatOV](https://www.reddit.com/user/boatOV) on
Reddit): on a 2D video, once you've turned more than 35° away from the screen for a second,
the screen and the panels glide round to where you're facing, so you never need to
recenter. A lazy follow: glances don't move anything, and nothing moves while you're
pointing at a panel, holding or dragging something. 180°/360° and 3D videos stay put.
## 0.98.2
### Added
- **The autoplay countdown in VR**: while YouTube counts down to the next video at a video's
end, the next video floats above the Autoplay button ("Up next in 5", its thumbnail, title
and channel), and hidden panels come back for it. Clicking it plays that video now;
switching Autoplay off cancels the countdown. Read from YouTube's own player (UNVERIFIED).
## 0.98.1
### Fixed
- **The end screen didn't show with the panels hidden**: like a skippable ad, it now brings
hidden panels back (the shell's panels get `host.reveal()`).
- **The previous video's end screen stayed on the next one**: just after a switch, the end
screen read a moment earlier (and the player's time) could still be the previous video's,
so the tab came straight back with the old tiles. The tiles now say which video they're
for, and a new video's end screen can only start once it has played for a moment.
## 0.98.0
### Added
- **The end screen in Up Next**: when a video's end screen starts (its last seconds), Up Next
gets an **End screen** tab with the creator's suggested videos and playlists, and other
channels, as cards; it switches to the tab, and opens if it was closed. The tab stays until
another video starts. A video plays, a playlist opens its page, a channel opens in the
Channel panel. The video's own channel and Subscribe tiles are left out (the controls panel
has both), as are links to other sites.
## 0.97.4
### Fixed
- **An info card's video showed first in Up Next**: reading Up next from the page also picked
up the description panel's info cards (YouTube keeps that panel in the same side column);
links inside YouTube's side panels are now skipped.
- **The info card teaser could come up with the panels hidden**: like Skip ad, a teaser now
brings the controls panel back, and it stays while the teaser shows.
## 0.97.3
### Changed
- **The Cards menu shows the cards as videos**: each row has the video's thumbnail with its
length, its title, and channel · views · age (the shell's menus can now show a picture on
a row's left).
- **The info card teaser is clickable**: pointing at it and clicking plays the card's video.
## 0.97.2
### Fixed
- **Info cards read from where YouTube keeps them**: the video's description data (its
"infocards" section), so the Cards menu lists the real cards (title, then channel, views
and age), and the button is greyed only on videos without any.
- **The teaser now follows YouTube's own**: the cards' times aren't in YouTube's data, so the
teaser floats above the Cards button while YouTube's player shows its card teaser (read
from the player, UNVERIFIED), with the card it's about. The "View corrections" teaser every
video has is ignored.
## 0.97.1
### Fixed
- **Every video seemed to have a "View corrections" info card** (the Cards button never
greyed out): YouTube's card list holds that teaser on every video, with nothing behind it
but its corrections panel. It's left out; the real cards are still to read (they weren't in
that list on a video that has them).
## 0.97.0
### Added
- **Info cards**: a **Cards** button on the controls panel (after Captions; greyed on a
video without cards). Its menu lists the video's cards (title, what it is, when it comes);
a choice plays the card's video or playlist (a link to another site says it opens on
desktop). When a card's time comes, its teaser floats above the button for 6 seconds,
like the Previous / Next hover card, and the button's icon fills.
- **Autoplay** on the controls panel (next to Play, on the right): YouTube's own switch,
On / Off under its icon.
- The shell: a site button can have a `teaser` (a card floating above it); the `autoplay`
icon.
### Changed
- **The controls panel is wider** (1408 px, was 1216) so its 13 buttons keep their full size
and the row stays even on both sides of Play.
## 0.96.6
### Fixed
- **The pill row jumped back to its start after picking a pill** (seen on Gaming's game
shelves): a chosen section reads fewer pages, so it offered fewer pills; the pills already
offered now stay, with new ones added after them. A sliding row also keeps its place when
more pills arrive at its end.
## 0.96.5
### Added
- **Gaming's game shelves** (signed in): each game's row of videos on YouTube's Gaming page
(Fortnite, Red Dead Redemption 2, Half-Life: Alyx ...) is now a pill on Explore's Gaming
tab, with its videos; a pill's "View all" leads to the game's own page. The game cards
themselves ("Top live games", Playables) are left out: they're games, not videos.
## 0.96.4
### Added
- **A shuffle card on Next while shuffle is on**: pointing at the controls' Next shows "A
random video from this playlist · Shuffle is on" with the shuffle icon, instead of no card
(the next video isn't known until it plays). With shuffle off, the next video's card as
before. The generic card takes an `icon` drawn in place of a missing picture.
## 0.96.3
### Changed
- **No Next preview while a playlist is shuffled**: YouTube only picks the next video when it
gets there (its own Next preview shows the in-order one too), so the controls' Next shows
no card instead of a wrong one. Reverts 0.96.2's attempt to read it from the player's Next
button (it showed the in-order video).
## 0.96.2
### Fixed
- **Next showed the in-order video with shuffle on**: the controls' Next (its hover card)
now shows the video YouTube will actually play next, read from the player's own Next
button while the playlist's shuffle is on (UNVERIFIED that its link follows the shuffle).
- **Save showed different states in two panels**: saving or removing a playlist now shows at
once on every open page of it (Up Next's playlist tab and a page opened from a card).
## 0.96.1
### Changed
- **Up Next's playlist tab is now the playlist as a page** (as when a playlist is opened from
its card): its picture, title and position, **Shuffle** (an on / off toggle) and **Save**,
then all its videos as rows, the playing one marked and brought into view when it changes.
- **A playlist card in Up Next's Recommended opens its page** in Recommended's place (a back
button returns), instead of playing at once. Mixes still play at once.
- The Queue style can show a collection page, scrolling.
### Fixed
- **Shuffle only shuffled the first video**: after YouTube's Shuffle play, StreamXR now also
turns on the watch page's own playlist shuffle once it shows (UNVERIFIED: the toggle is
found by its "Shuffle" label).
## 0.96.0
### Added
- **A playlist's page before playing it**: clicking a playlist card in Explore, You, Search
or the Channel panel opens the playlist in that panel: its picture, title, author, stats
and description, **Play all**, **Shuffle** and **Save** (to your library, signed in), then
its videos as rows, more loading as you scroll. A row plays the playlist from that video.
**Back** (or the tab again) returns to the list; the playlist stays open while you use
other panels. Mixes still play at once.
- The shell's **collection page** (`styles/collection.js`), shown by the Catalog and Profile
styles under a back button; the Catalog style can now scroll it. The `shuffle` and
`playlist_add_check` icons.
## 0.95.9
### Added
- **"Members only" on members-only posts**: the same Members only flag as on members-only
video cards, after the channel's name (in the Membership tab's Posts, the Posts tab and an
opened post). The Profile style's posts take `flags` for this.
## 0.95.8
### Added
- **Your membership in the Channel panel**: on a channel you're a member of, the Membership
tab has three pills: **Perks** (your level, how long you've been a member, and your perks
with their custom emoji and loyalty badges), **Videos** (YouTube's highlighted
members-only videos) and **Posts** (recent members-only posts, with likes, polls and
comments as on the Posts tab).
## 0.95.7
### Added
- **The Channel panel's Podcasts tab**: the channel's podcasts, their square covers shown
whole in the cards.
### Fixed
- **The Channel panel said "Subscribe" on a channel you're subscribed to** (seen on one
you're a member of): YouTube's answer for the channel now wins over the first guess from
the watch page, which could be the previous video's. A subscription change also drops the
channel's saved answer, so reopening it shows the change.
- **No Membership tab on a channel you're a member of**: members get YouTube's Membership tab
but no Join button, so the tab now shows whenever the channel has one. Without a Join offer
it says your membership is on desktop for now.
## 0.95.6
### Removed
- The members-only dialog's console diagnostics (0.95.4): the cause is fixed (0.95.5, works
in the headset).
## 0.95.5
### Fixed
- **The members-only dialog on a normal video opened right after a members-only one** (found
with 0.95.4's log): the checks behind it were cached for half a second, so just after the
switch the new video got the previous video's answers ("won't play", "members only"). Those
checks now start over the moment the playing video changes.
## 0.95.4
### Changed
- **Diagnostics for the members-only dialog** (it still comes up on a normal video opened
right after a members-only one): each time it comes up, the console logs what it was decided
from ("[streamxr] members-only dialog for …": the address, the playing video, the player's
and the watch page's data), to find the cause.
## 0.95.3
### Fixed
- **The members-only dialog on a normal video, again:** a video now counts as members only
only when the watch page itself marks it so (the "Members only" badge under its title),
read from the page's data for the playing video, so the player's leftover status from the
previous video can't set it off. The dialog also asks again only after a normal video has
played for a moment.
## 0.95.2
### Fixed
- **The members-only dialog still came up on a normal video opened right after one:** the
player's leftover data for the members-only video doesn't name its video, so the check in
0.95.1 couldn't tell it was stale. The data last seen with the previous video is now ignored
until the player has the new video's.
## 0.95.1
### Fixed
- **The members-only dialog** showed only OK on a members-only video (it asked before the page
had the offer: it now waits up to 3 s for it), and came up again on the next, normal video
(the player still held the previous video's status for a moment: only the playing video's
counts now). It also comes back each time you open a members-only video again.
## 0.95.0
### Added
- **Members-only videos offer their channel's membership:** opening one brings up a dialog
("Members only: <channel>", YouTube's line about the level it needs) with **See perks** and
OK. See perks opens the channel's new **Membership** tab in the Channel panel.
- **The Membership tab** (signed in, channels with memberships): YouTube's "Join this channel"
offer in VR: the channel's picture, each level with its price, its perks (with their badges
and emoji) and its disclaimer, and the footnote. Joining stays on desktop: each level shows
a greyed **Join on desktop** button. A creator card in Explore → Memberships opens it.
- For other sites (ARCHITECTURE.md): the Profile style's **'tiers'** page (plans or levels to
join: each with a title, price, button, items with small pictures, a note).
## 0.94.6
### Fixed
- **Explore → Memberships** only offered "Creators for you" as a creators' pill: YouTube puts
"Popular creators", "Trending content", "Up-and-coming" and "Loved by members" in one block
of the page, and only its first shelf was read. Every shelf in a block is its own pill now
(on every Explore page).
- **Creator cards** show the channel's banner behind its round picture (no more plain grey).
## 0.94.5
### Fixed
- **Memberships' creators didn't show signed in** ("Creators for you", "Popular creators",
"Up-and-coming"): their cards carry the channel in a different encoded parameter then; the
reader now looks in all of them.
## 0.94.4
### Changed
- **A video YouTube won't play** (e.g. members only) now shows its thumbnail on the screen,
and the **Play** button is greyed out, besides the red line saying why.
- **The Channel panel follows the playing video:** while it shows the playing video's channel,
it switches to the next video's channel when another video starts (unless you've gone to
another channel, a search or a post in it).
- For other sites (ARCHITECTURE.md): a provider's `poster()` (pictures the screen shows in
place of a video the site won't play) and the state's `playable`.
### Fixed
- The playing video's channel is read from the watch page when the player's data lacks it (a
video YouTube won't play).
## 0.94.3
### Fixed
- **Podcasts' "All" pill** now shows square cards too: a list that's mostly podcasts uses the
square layout, its few regular videos shown whole inside their square.
- **A video YouTube won't play** (e.g. one for channel members only) showed nothing in VR: the
controls panel's line under the title now says why, in red ("Members only: join this
channel on desktop to watch it." for members-only videos).
## 0.94.2
### Added
- **Square podcast cards:** a list of podcasts (Explore → Podcasts) shows square cards with
each show's own square cover, a **Podcast** flag and its episode count (12 to a page);
sections with regular videos (e.g. "Live now") keep the usual cards.
- **Memberships:** the creators to join ("Popular creators", "Up-and-coming") show as their own
pills, as cards with the creator's round picture, name and monthly price; clicking one opens
their channel. Joining stays on desktop.
- **Members only** flag (green) on videos for channel members only.
- For other sites (ARCHITECTURE.md): the card grid's **square layout** (`shape: 'square'`,
Catalog's `perPageFor(shape)`).
### Fixed
- An Explore page whose "load more" sits at the end of its own section list stopped after its
first page.
## 0.94.1
### Changed
- **Explore's pills show the whole shelf:** picking a section's pill (Live's "Live Now", Hype's
"Top 100" ...) shows everything YouTube's "View all" leads to (its own page, loading more as
you page through, or its playlist), not just the shelf's first cards.
- **More sections as pills:** a page's later sections (e.g. Gaming's game shelves, signed in)
are read up front, so they're offered too.
- **News** shows its own tabs as pills: Top stories, Sports, Entertainment, Business,
Technology, World, National, Health.
### Fixed
- **Mixes had no thumbnails** (Music): YouTube's mix art can't be drawn in VR (its image server
refuses), so a mix shows its first song's thumbnail instead.
- The pointing ring round the selected tab or pill was cut at either end of a row that slides.
## 0.94.0
### Added
- **Explore's Live, Gaming, Music, Hype, Courses, Podcasts and Memberships tabs** (until now
"Coming soon"): each shows YouTube's page of that name, with its sections as pills ("All"
first, then e.g. Live Now, Recent Live Streams, Upcoming Live Streams on Live). Podcasts and
Courses open as playlists. Shorts, Movies & TV and Learning are still to come.
## 0.93.4
### Fixed
- **Seams on 360° cubemap videos** (reported by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack) on Reddit and [@MinuteHistoryX](https://x.com/MinuteHistoryX) on X):
thin lines along the cube's edges on YouTube's 360° "mesh"
videos, with the glass look on. The cubemap is now read at full resolution (the glass's
blurred copies of the video made the edges pick up their smallest one); regular 360° videos
never had them.
## 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** (asked for by [@damics80](https://x.com/damics80)): 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** (the VR180 ones spotted by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)): 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
@@ -20,7 +619,7 @@ developers are in [ARCHITECTURE.md](ARCHITECTURE.md) and [docs/](docs/).
A press that closes a menu, a dialog or typing still only does that; the next one hides.
Another pull, or pointing at a panel, brings them back. Panels set to stay up (the live
chat) stay.
- **Settings → Auto-hide → "Hide while paused"** (suggested by u/TehSuperFruitSnack; off by default, remembered): while on, the
- **Settings → Auto-hide → "Hide while paused"** (suggested by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack); off by default, remembered): while on, the
auto-hide waits also run with the video paused, so you can pause and take in a 180° / 360°
scene with nothing in the way. The waits themselves are unchanged.
+76 -52
View File
@@ -7,43 +7,46 @@ 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-v0881) · [Roadmap](#-roadmap) · [How you can help](#-how-you-can-help)
[Your privacy](#-your-privacy) · [What's new](#-whats-new-in-v01017) · [Roadmap](#-roadmap) · [How you can help](#-how-you-can-help)
> **Early access** (version 0.88). Free and open source, forever. It already works every day; it's
> **Early access** (version 0.101). Free and open source, forever. It already works every day; it's
> still growing fast, and your feedback shapes what comes next.
>
> StreamXR is vibe coded by one guy (me!), and the only headset I've got is the Steam Frame.
> Bug fixes, updates and support for more headsets and sites are coming, so please be
> patient with me. 😭🙏
## ✨ What's new in v0.88.1
## ✨ What's new in v0.101.7
The latest release, in short (every version's details are in the [changelog](CHANGELOG.md)):
- 💬 **Live chat:** on a live stream, the chat opens on the right and stays up while you watch.
Read it with emojis, badges, Super Chats and the pinned message, tap the stream's
reactions (❤ 😄 🎉), and type messages on the VR keyboard, which now has emoji pages
(with your channel's member emojis). A **Support on desktop** button points you to YouTube's
window for Super Chats and memberships.
- 🏆 **Top fans:** your **XP** in the chat, the ranked list of the stream's top fans, and
the top three's #1 / #2 / #3 pills.
- ⏪ **Chat replays:** on a finished stream that kept its chat, the chat plays along with the
video. It pauses when you pause and follows when you jump back or ahead.
- ✋ **Move the side panels:** grip the grab bar beside either side's toolbar and put that side
where you like. Recentering keeps it there, unless you turn on Settings → Safe zone →
**Recenter resets panels**.
- 👆 **Hide everything in one pull** (suggested by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)): pull the trigger on empty space and the panels, toolbars
and search bar go away at once. Pull again to bring them back.
- ⏸️ **Settings → Auto-hide → Auto-hide while paused** (suggested by u/TehSuperFruitSnack too): pause and soak in a 180° / 360° scene
with nothing in the way (off by default).
- 🎚️ **Quality menu icons:** each quality shows its 4K / Full HD / HD (and 60 fps) icons, as the
Quality button does.
- 🖐️ **Clearer controller icons:** the grip shows a hand when it will grab something, and the
left D-pad shows a panel icon for show / hide.
- 🥽 **Meta Quest 2 reported working:** [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack) watches through SteamVR on a Quest 2, 8K videos included. Thank you! Other headsets: [tell me how yours does](#-adding-a-headset).
- 🩹 **Fixes:** the chat now tries again on its own when YouTube's page hasn't loaded it yet;
typing in the chat and the search bar no longer shows two text cursors; long dialog
messages are no longer cut off.
- 🧭 **Explore's new tabs:** **Live**, **Gaming**, **Music**, **Hype**, **Courses**, **Podcasts**
and **Memberships**, each with its sections as pills (Gaming's game shelves too, signed in),
and "View all" behind each one. Podcasts show with their square covers.
- 📃 **See a playlist before playing it:** click a playlist card anywhere (Explore, You,
Search, Channel, Up Next) to open its page: details, **Play all**, **Shuffle**, **Save**, and
its videos as a list. Up Next's playlist tab shows the playing playlist the same way.
- 📋 **Add to queue:** point at a video card and click the queue button on its thumbnail; the
queue gets its own tab in Up Next and plays after the current video. **Clear** empties it,
and **Save** keeps it as a new playlist (named on the VR keyboard) or adds it to Watch later
or one of yours.
- ℹ️ **Info cards:** a **Cards** button on the controls panel lists the video's cards, and
each card's teaser pops up above it at the right moment (click it to watch).
- 🔚 **End screens:** when a video's end screen starts, Up Next shows its suggested videos,
playlists and channels, and **Autoplay** (now a button on the controls panel) shows what's up
next with a countdown.
- 🧲 **Follow your head:** turn it on in Settings → 2D Cinema, and on regular videos the screen
and panels glide back in front of you when you turn (with adjustable delay and speed).
Thanks to [u/boatOV](https://www.reddit.com/user/boatOV) for asking for it!
- ⭐ **Channel memberships:** members-only videos say so (and show their thumbnail), a
channel's **Membership** tab shows its levels and perks, and on channels you're a member of:
your level, perks, members-only videos and posts.
- 🎛️ **A wider controls panel** with room for the new buttons, and **Settings tabs that
scroll** when they have more than fits.
- 🩹 **Fixes:** no more seams on 360° cubemap videos (thanks to
[u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack) and
[@MinuteHistoryX](https://x.com/MinuteHistoryX) for reporting them!), the Channel panel's
Subscribe button, and many smaller ones.
## 💡 Why I made this
@@ -111,7 +114,8 @@ more likely they are to open the door. **If and when they do, I'll make it work
## 🌟 What you get
### 🎥 Every kind of video, the way it was meant to be seen
- **360° and VR180** wrap around you (YouTube's cubemap "mesh" format included); the format
is detected automatically and can be changed any time.
is detected automatically and can be changed any time. **3D side-by-side** videos play in
real 3D, as VR180 or on the cinema screen (pick it in the Format menu).
- **Regular videos** play on a **cinema screen** you can **curve**, **resize**, **move** and
**push away**, with **rounded corners** and an optional **ambient glow** that spills the
picture's colors around it, like a TV with backlighting. 🌈
@@ -126,9 +130,12 @@ more likely they are to open the door. **If and when they do, I'll make it work
- Title, views, date and the channel, with **Subscribe**, **Like** and **Dislike**.
- A **seek bar** with chapter marks, a **preview frame**, YouTube's **Most Replayed** curve
and how much has **loaded**.
- **Quality** (8K, 4K, 60 fps...), **speed**, **volume**, **format**, **recenter** and exit.
- **Quality** (8K, 4K, 60 fps, and **1080p Premium** 💎 with Premium), **speed**, **volume**,
**format**, **recenter** and exit; **Jump ahead** 💎 at the seek bar's end when YouTube offers it.
- **💬 Captions** in any language the video has, drawn right in VR.
- **🔖 Save** to Watch later or any of your playlists.
- **ℹ️ Info cards** with their teasers at the right moment, and **Autoplay** with its
"Up next" countdown.
- Playlists get **Previous / Next** with a preview card; live streams get a **LIVE** label
that jumps you back to live.
- It fades away while you watch, and one pull of the trigger brings it back.
@@ -137,11 +144,15 @@ more likely they are to open the door. **If and when they do, I'll make it work
Laid out around a **Safe Zone** you can tune, so the picture always stays clear, with
glassy toolbars on each side:
- ⬅️ **About this video:** **Description** (with chapters and the transcript), **Comments**
and **Channel** (videos, Shorts, live streams, playlists, posts, About, Subscribe).
and **Channel** (videos, Shorts, live streams, playlists, podcasts, posts, membership,
About, Subscribe).
- 🔎 **Search** above the video, with suggestions as you type on the **VR keyboard**.
- ➡️ **What to watch:** **Up Next**, **Explore** (your Home feed with all its topic chips,
"Immersive videos" included, plus News) and **You** (Subscriptions, Your channel,
History, Playlists, Watch later, Liked videos).
- ➡️ **What to watch:** **Up Next** (with your **queue** and the video's **end screen**), **Explore** (your Home feed with all its topic chips,
"Immersive videos" included, plus Live, Gaming, Music, News, Hype, Courses, Podcasts and
Memberships) and **You** (Subscriptions, your Channels with their
notification bells, Your channel,
History, Playlists, Watch later, Liked videos). Any playlist opens as a page you can
browse before playing, and any video card can be **added to the queue**.
- 💬 **Live chat** on live streams (and **chat replays** on finished ones, playing along with the
video): it opens by itself and stays up while you watch. Read it with emojis, badges, Super
Chats and the pinned message; send reactions and type messages on the VR keyboard (with your
@@ -151,7 +162,8 @@ glassy toolbars on each side:
### 🎨 Make it yours (Settings)
- Looks: **YouTube** or **Material 3** colors, **glass** panels with an adjustable tint, the
**panel size**.
- The cinema screen's **curve**, **distance** and **glow**.
- The cinema screen's **curve**, **distance** and **glow**, and **Follow your head** (the screen
and panels catch up when you turn).
- The **Safe Zone**, how long each part waits before it **fades**, the **laser's
steadiness**, the **keyboard layout** and the **language**.
@@ -321,15 +333,17 @@ what bugs you most):
vibrate yet, so Settings → Appearance → Vibration is greyed out.
- **Only Steam Frame controller models.** Other controllers are drawn as Steam Frame
controllers. Besides the Steam Frame, only the Quest 2 has been reported working (by
u/TehSuperFruitSnack); other headsets are untested. **Got 3D models of your controllers?** 🧊
[u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)); other headsets are untested. **Got 3D models of your controllers?** 🧊
Share them in a **Headset / controller compatibility** issue (with where they came from and
their license) and I'll integrate them once I start accepting pull requests. They need a
license that lets them be shared and changed in a free project, with credit (for example
CC BY, CC BY-SA, CC BY-NC-SA or CC0), like the Steam Frame models' (CC BY-NC-SA 4.0).
**🎬 Video**
- **No 3D (stereo) videos.** On desktop, YouTube only sends one eye's picture, so 3D videos
play flat.
- **Most 3D (stereo) videos play flat.** On desktop, YouTube sends only one eye's picture for
most 3D videos (and caps some VR180 videos at 4K), while the Meta Quest app gets both eyes.
StreamXR shows what YouTube's own player plays and won't bypass it: [why](docs/decisions/0002-site-player-only.md).
The 3D videos that do arrive with both eyes side by side play in 3D (Format menu).
- **Shorts are very tall** on the cinema screen: they keep their vertical shape at the
screen's full width. Honestly, I left this in because I think it's funny, but I'll fix it
eventually lol 😂
@@ -347,8 +361,7 @@ what bugs you most):
- **Comments show one level of replies;** YouTube's newer replies-to-replies aren't shown
yet.
- **Glass menus blur only the video,** not the panels behind them.
- **Some Explore tabs say "Coming Soon"** (Shorts, Music, Live, Gaming and more): they're
on the roadmap.
- **Two Explore tabs say "Coming Soon"** (Shorts and Movies & TV): they're on the roadmap.
- A channel's **Featured** tab only shows for channels you subscribe to when the creator
picked a video for returning subscribers (that's YouTube's behavior).
@@ -399,18 +412,29 @@ which is often all that's needed. 📋
No dates, just what I'll work on after release, in this order:
1. 🐛 **Immediate bug fixes:** whatever the first wave of users runs into.
2. 🏪 **Chrome Web Store release:** install StreamXR in one click and get updates
automatically, no more downloading new versions by hand. Still free, of course.
3. 💎 **YouTube Premium features:** Premium quality levels, Add to queue and more.
4. 🧭 **Explore page tabs:** Live, Gaming, Music, Podcasts and the rest of the "Coming Soon"
tabs.
5. 💬 **Live Chat:** See the chat of a Live Stream while watching
6. 💬 **Commenting and Live Chat in-headset:** write comments, replies, and type in a live chat with the VR keyboard.
7. 🟣 **Twitch.tv support:** the first site after YouTube.
8. 🔐 **Groundwork for YouTube Movies, Netflix, HBO Max, Disney+ and other protected videos:** the
infrastructure to play them in VR, ready for the day they allow it
([why they don't yet](#-what-about-netflix-max-and-the-others)).
9. ✨ **Whatever else I can think of** (and whatever you ask for!).
2. ✅ **Add to queue:** done (v0.101.7): line up videos to watch next from any panel,
then clear it or save it as a playlist.
3. 🧭 **Explore page tabs:** Live, Gaming, Music, Hype, Courses, Podcasts and Memberships are
done (v0.101.7); Shorts and Movies & TV are next.
4. ✅ **Live Chat:** done (v0.88.1): read, react and type in a live stream's chat.
5. ✅ **YouTube Premium:** done (v0.93.3): 1080p Premium and Jump ahead, marked with the Diamond.
6. 💬 **Commenting in-headset:** write comments and replies with the VR keyboard (typing in a live
chat is already in).
7. 🏪 **Chrome Web Store release, StreamXR 1.0:** once the YouTube features are all in, install
StreamXR in one click and get updates automatically, no more downloading new versions by
hand. Still free, of course.
8. 🟣 **Twitch.tv support:** the first site after YouTube.
9. 🪼 **Jellyfin** and 🟠 **Plex support:** your own media server's movies and shows in VR, on the
cinema screen. Your own files aren't copy-protected, so they will be able to play.
10. 🎵 **Music streaming**, starting with **Spotify:** listen while you're in VR, with the player's
controls, album art and queue around you.
11. 🌐 **Barebones mode (experimental):** any video on any website on the cinema screen, with the
basic controls (play, seek, volume, format), for sites without full support. Some sites
won't allow it.
12. 🔐 **Groundwork for YouTube Movies, Netflix, HBO Max, Disney+ and other protected videos:** the
infrastructure to play them in VR, ready for the day they allow it
([why they don't yet](#-what-about-netflix-max-and-the-others)).
13. ✨ **Whatever else I can think of** (and whatever you ask for!).
### ⏳ The "as soon as it's possible" roadmap\*
@@ -527,7 +551,7 @@ A reusable VR **shell** in two parts, the **core** (`src/core/`: the VR interfac
and plugs in through its **provider** (a middle layer), its panels (built on five standard
styles) and its page buttons. No build step and no ES modules: plain scripts loaded in
order by `manifest.json` (one vendored library, Google's Material Color Utilities, in
`third_party/`). There are no 3D formats: on desktop YouTube sends one eye only.
`third_party/`). Most 3D videos reach desktop as one eye only, and StreamXR never bypasses the site's player to get more ([decision 0002](docs/decisions/0002-site-player-only.md)).
| Read | For |
|---|---|
+2 -2
View File
@@ -113,11 +113,11 @@ Measured from `config.js` (the mock-up prints these):
|---|---|---|
| Open panel (Browse) | centre −7°, 50° wide × 34° tall, 1.08 m away | centre −5°, ≈ 48° wide, 1.75 m |
| Toolbar | between the controls and the panel, −28° | navigation never between panels |
| Controls panel | centre −37°, 44° × 12°, 1.0 m | an orbiter under its panel |
| Controls panel | centre −37°, 50° × 12° (44° until 0.97.0), 1.0 m | an orbiter under its panel |
| Whole stack | +10° to −43° tall | primary content in the central 41° |
Already as the guides ask: one panel at a time; panels fixed in the room with Recenter (no
head lock); move / resize; hover, pressed and selected states and the white ring; motion;
head lock; since 0.99.0 an optional lazy follow on 2D videos, Settings → 2D Cinema); move / resize; hover, pressed and selected states and the white ring; motion;
controls in their own panel (not over the video); a dark surround; frames within the
budget (smooth in the headset); text at or above 14 dp; **haptics on hover and click**
(`config.js haptics`, no off switch yet); a passthrough option for when browsers offer it.
+70
View File
@@ -0,0 +1,70 @@
# 0002: Play only what the site's own player plays
- **Status:** accepted (the maintainer, 2026-10-17)
- **Prompted by:** a tester's report (u/TehSuperFruitSnack on Reddit) that the Meta Quest app
shows VR180 videos in 3D and up to 8K, while StreamXR shows one eye and 4K at most.
## Context
StreamXR draws the `<video>` element that the site's own player is already playing; it never
fetches a video stream itself. On desktop that has limits (checked in the browser, see
[youtube.md](../youtube.md) "Formats and VR180"):
- **Most 3D VR180 videos reach desktop as one eye only.** YouTube removes the right eye
before the stream reaches the browser (a "LEFT / RIGHT" test video shows LEFT in both
eyes). Some videos reach desktop with both eyes side by side; those StreamXR can show in
3D (a format to pick by hand).
- **Some VR180 videos top out at 4K on desktop** (the player offers 2160s at most), while
others reach 8K.
The Quest app most likely identifies itself to YouTube as a VR client and gets the full
stereo, higher-resolution versions. Matching it would mean **bypassing YouTube's player**:
asking YouTube for the streams as if StreamXR were that app, and playing them in a player of
our own.
## Decision
**StreamXR doesn't bypass the site's player.** It shows what the site's own player plays.
If a site starts sending stereo or higher resolutions to browsers, StreamXR shows them. This
holds for every site, not just YouTube. Suggestions to bypass the player (from users,
contributors or forks) get pointed here.
## Why: the risks of bypassing the player
1. **Rules and distribution.**
- YouTube's Terms of Service don't allow accessing videos other than through YouTube's
own player and interfaces, or getting around its ads.
- The Chrome Web Store rejects extensions that break a site's terms or enable
unauthorized downloading or streaming; that would block the planned store release.
- It invites legal trouble: in 2020 the RIAA had youtube-dl taken down from GitHub,
arguing that working around YouTube's scrambled stream addresses ("rolling cipher") was
circumvention. GitHub reinstated it, but the claim was made.
2. **It breaks constantly.**
- Posing as another app means copying its client name, version and request format,
which YouTube changes often and blocks when they're not genuine.
- Stream addresses are scrambled by code that changes with player updates, and YouTube
increasingly requires proof-of-origin tokens (BotGuard) that are hard to produce
outside its own player.
- Tools like yt-dlp need fixes every few weeks for this; StreamXR would join that race.
3. **Rebuilding the site's player.**
- StreamXR would need its own player feeding the video and audio streams to the browser
(Media Source Extensions) and choosing qualities.
- Captions, audio tracks, quality switching, live streams, chat replays tied to the video
time, ads and Skip ad, and the resume position all come from YouTube's player today;
each would need rebuilding or would break.
4. **Ads and creators.** Playing the streams directly skips YouTube's ads: the thing YouTube
fights hardest, and revenue the creators people are watching depend on.
5. **Users' accounts and privacy.**
- Requests sent with the user's signed-in session while posing as another app are the
kind of traffic YouTube flags; at worst, an account gets warnings, extra verification
or restrictions.
- It needs a host permission for YouTube's video servers, against the project's
"minimal permissions" goal, with a broader warning for users.
## Consequences
- 3D on one-eye VR180 videos, and more than 4K on those YouTube caps on desktop, are
documented as desktop YouTube limits (the README's "Known issues"), not open work.
- What desktop YouTube does send is used fully: 8K where offered, and (planned) a side-by-side 3D
format for videos that arrive with both eyes.
- A fork may decide otherwise; it then takes on the risks above itself.
+81
View File
@@ -276,3 +276,84 @@ 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).
- **Explore's pages (the maintainer, 2026-10-18, after 0.94.0):** each page's sections are its
pills, and a pill shows the whole section (YouTube's "View all"). **News** stays an Explore
tab, its own tabs (Top stories, Sports ...) as its pills. **Learning** is deferred (an AI
prompt with generated lessons; Courses covers learning). **Podcasts** get **square cards**
for their square cover art. **Memberships** stays in Explore: its channel cards show the
channel's picture, name and price and open the channel; members-only videos are flagged;
joining stays on desktop. Viewing a playlist before playing it comes after Explore.
- **Members-only videos and memberships (the maintainer, 2026-10-18):** opening a members-only
video brings up a short dialog (Members only, the channel, YouTube's "choose a level that's
at least …" line and the cheapest level) with **See perks** (opens the channel's
**Membership** page in the Channel panel: each level with its price, perks and disclaimer)
and OK. A creator card in Explore → Memberships opens that Membership page. Joining stays
on desktop: each level shows a greyed **Join on desktop** button.
- **Info cards and Autoplay on the controls panel (the maintainer, 2026-10-19, 0.97.0):** the
panel is wider (1408 px, was 1216) so two more buttons keep full size and the row stays
even on both sides of Play: **Cards** at the inner end of the left part (after Captions;
always there, greyed on a video without cards, so the row never shifts; a menu of the
video's cards; as a card's time comes, its teaser floats above the button like the
Previous / Next hover card, nothing drawn over the video) and **Autoplay** at the inner end
of the right part (before Save; YouTube's own switch, On / Off under its icon). The teaser
is clickable (plays its card) and, like Skip ad, brings hidden panels back while it shows; the
Cards menu shows each card as a video row (thumbnail, length, title, channel · views · age).
- **The end screen goes in Up Next (the maintainer, 2026-10-19, 0.98.0):** not a raised row
over the controls (a large raised area, reaching into the video) nor on the video (YouTube's
way: over the picture, and no screen for 360° / 180°). When it starts, an **End screen** tab
appears in Up Next with its videos, playlists and other channels as cards; Up Next switches
to it and opens if it was closed; it stays until another video starts. The video's own
channel and Subscribe tiles are left out: the controls panel already has both.
With Autoplay on, YouTube's countdown to the next video shows as a card above the Autoplay
button ("Up next in 5": the next video, clickable to play it now; switching Autoplay off
cancels it), bringing hidden panels back like a teaser.
- **Follow your head on 2D videos (the maintainer, 2026-10-19, 0.99.0; asked for by [u/boatOV](https://www.reddit.com/user/boatOV)):**
a lazy follow, not a head lock (XR guidelines advise against content locked to the head):
once you've faced more than 35° sideways or 25° up / down away for the Delay, the screen and the panels ease round to
where you face at the Speed (both sliders, 0.99.2); glances don't move anything, and nothing
moves while you're using or looking at a panel.
A switch in Settings → 2D Cinema, off by default; 2D videos only (not 3D, 180° or 360°).
- **Add to queue (the maintainer, 2026-10-19, 0.100.0):** a round **Add to queue** button on a
video card's thumbnail, bottom-left (the flags take the top), shown while the card is pointed
at; one click queues (a check once queued), the rest of the card still plays. The queue has
its own **Queue** tab in Up Next, which appears without Up Next switching to it (YouTube's queue takes the playing playlist's place, so
it's that tab, named Queue). It drives YouTube's own queue, the command its card menu sends.
The Queue tab has **Clear** (a dialog asks first, as YouTube's Undo can't be reached) and
**Save** (a menu: New playlist… named on the VR keyboard, then Watch later and your playlists;
the maintainer, 2026-10-20). While its menu or dialogs are up, the button is filled in
YouTube's red; the Save menu drops down under its button (Material menus open below their control), kept clear of the side toolbar.
- **The Membership tab for a member (the maintainer, 2026-10-18):** on a channel you're a
member of, the Membership tab has three pills: **Perks** (your level, "Member · 3 months" and
your perks with their emoji and loyalty badges, on the same page as the Join offer), **Videos**
(the "Highlighted content for members", as the usual card grid) and **Posts** (the recent
members-only posts, as the usual feed).
- **A playlist's page (the maintainer, 2026-10-18):** clicking a playlist card opens the
playlist **inside the panel it's in** (not a hover popup: hover stays feedback only, and
large scrolling areas aren't raised, docs/Spatial-Layout.md): a back button, its picture,
title, author, stats and description, **Play all · Shuffle · Save**, then its videos as
compact rows (number, thumbnail, title, a grey line) rather than full cards, so more fit.
The panel keeps it open while you use other panels (like an opened post); Back, or its
tab again, returns to the list. A mix still plays at once (it has no page).
Up Next shows the same page for the playing playlist (its tab: Shuffle as an on / off toggle,
Save, every video as a row, the playing one brought into view), and opens a Recommended
playlist card the same way (the maintainer, 2026-10-18).
While a playlist is shuffled, the controls' Next shows a shuffle card ("A random video from
this playlist", the shuffle icon) instead of a video: the next one isn't known until it plays
(the maintainer, 2026-10-18).
+9 -3
View File
@@ -88,6 +88,12 @@ Also sorted easiest first; all need testing while signed in.
Re-check it on the right identity first (0.22.1 fix).
3. ~~**Liking posts, voting in polls, post comments**~~ DONE (v0.40.0: 👍 / 👎 / 💬 on each
post; 💬 opens the post with its comments; liking and voting signed in. All verified).
4. **Memberships ("Join")**: no Join button seen signed out; untested.
5. **Podcasts, Courses, Releases tabs**: named in the code's tab list but no tested
channel had them; their contents are unverified.
4. **Memberships ("Join")**: the Membership tab shows a channel's levels and perks (0.95.0,
works in the headset); joining stays on desktop. A channel you're already a member of
gets the tab (0.95.7) with Perks, Videos and Posts pills (0.95.8; all work in the
headset): your level, length and perks, the highlighted members-only
videos, and the recent members-only posts (flagged "Members only", 0.95.9, works in the
headset).
5. **Podcasts, Courses, Releases tabs**: Podcasts has a tab (0.95.7, works in the
headset). Courses and Releases are named in the code's tab list but no tested channel
had them; their contents are unverified.
+18 -1
View File
@@ -34,7 +34,7 @@ are the extra work for each feature once the panel exists.
- Code: `home(chip)`; `p.chips` lists the chips on offer (personalised: News, Music,
Gaming, Live, Recently uploaded, Watched ...)
- VR idea: a scrollable row of chip buttons above the grid.
3. [ ] **Explore feeds: Live, Gaming, Music, Hype, Courses, Podcasts, Memberships** (small to medium; v0.74.0: each
3. [x] **Explore feeds: Live, Gaming, Music, Hype, Courses, Podcasts, Memberships** (0.94.0 to 0.95.6, work in the headset: each a tab with its sections as pills, "View all" behind each pill, square podcast cards, Memberships' creators and the Membership tab; Gaming's signed-in game shelves as pills since 0.96.5, work in the headset (the pill row keeps its place: 0.96.6); v0.74.0: each
is a "Coming Soon" tab of the Explore panel, with Shorts, Movies & TV and Learning, ready to fill)
- Code: `feed(name)` -> sections [{ title, items }] ("Live Now", "Upcoming Live
Streams", "Top 100" ...)
@@ -60,10 +60,27 @@ are the extra work for each feature once the panel exists.
- ~~Code: `suggestions(text)` -> YouTube's autocomplete~~ (verified Oct 2026: up to 12,
about 0.2 s, works from the youtube.com page)
7. [x] **A playlist's page before playing it** (0.96.0 to 0.96.2): clicking a
playlist card (not a mix: mixes have no page and still play at once) opens it in place of
the list, in Explore, You, Search and the Channel panel: its picture, title, author, stats
and description, **Play all**, **Shuffle**, **Save** (signed in, someone else's playlist),
then its videos as rows (a row plays from that video). Back, or the tab again, returns to
the list; leaving the panel keeps it open (0.96.0: details, rows, scrolling, Play all,
navigation, Save (not on your own playlists, as on YouTube) and Shuffle work in the headset).
Up Next (0.96.1, works in the headset): its playlist tab shows the
playing playlist the same way (Shuffle toggle, Save), and its Recommended playlist cards
open their page (untested: none showed up yet). Save shows at once on every open page
(0.96.2).
## Deferred
Left out for now by the maintainer; each can come back if there is demand for it.
- **Learning** (the Explore tab; the maintainer, 2026-10-18)
- What: YouTube's Learning page, an AI prompt ("What do you want to learn?") that generates
tabs, shelves and quizzes from the topic you type.
- Why: a different kind of page (typing a topic, AI-generated content of an unknown shape),
and Courses already brings learning content into VR. Its tab stays "Coming soon".
- **Shelves from the Home feed** (small)
- Why: Shorts are a poor fit for this extension, and the "Top news" shelf is rarely
used. The shelves' items are often less related to what's recommended elsewhere, so
+16 -3
View File
@@ -40,15 +40,19 @@ the same shape.
- VR idea: a Subscribe button on the Channel panel (`channel-page.md`) and on
Up Next cards. Also lets the controls panel's Subscribe button unsubscribe, which it
can't do today (YouTube's confirm menu blocks it).
2. [ ] **Shelves and posts in the feed** (small to medium)
2. [ ] **Posts in the feed**: deferred (0.90.2, see "Deferred"). The shelves (a Shorts row,
"Most relevant") are deferred too, as on Home.
- Code: `p.shelves` (Shorts row, "Most relevant"), `p.posts`
- VR idea: as on Home. Posts need the post cards from the channel Posts tab.
3. [ ] **Manage list: your subscribed channels** (medium)
3. [x] ~~**Manage list: your subscribed channels**~~ DONE (v0.90.0, works in the headset; sort pills 0.92.2: You →
Channels, round pictures, a click opens the Channel panel)
- Code: `channels()` -> [{ id, title, handle, subscribers, thumb, subscribed,
notifications }]
- VR idea: a list of channel rows (avatar, name, counts); clicking one opens its Channel
panel. Needs a scrolling list rather than the card grid.
4. [ ] **Notification bell per channel** (medium)
4. [x] ~~**Notification bell per channel**~~ DONE (v0.90.0, works in the headset: the bell button
beside Subscribed on the Channel panel, a floating menu of YouTube's choices; the Channels
list shows each setting)
- Code: `setNotifications(option)` with one of a channel's `notifications.options`
(All / Personalized / None); `[]` means YouTube offers no bell for that channel.
- VR idea: a bell button on each Manage row and on the Channel panel, opening a small
@@ -78,3 +82,12 @@ needed for now (Subscribe from the controls panel and the Channel panel works in
3. **Watch later / Liked videos / your playlists**: their browse ids are in
`youtube-home.js` (`feed('watchLater')`, `feed('liked')`) but untested signed in;
saving a video to a playlist needs another request.
## Deferred (the maintainer, 2026-10-17)
- **Shelves in the Subscriptions feed** (a Shorts row, YouTube's "Most relevant"): for the same
reason as Home's shelves (`home-page.md`, "Deferred"): Shorts fit VR poorly. `p.shelves` is
still read.
- **Posts in the Subscriptions feed** (0.90.2): desktop's feed doesn't mix community posts in
(a probe signed in found no post data in `FEsubscriptions`), so the Posts pill built in
0.90.0 was always empty and was taken out. Posts stay on each channel's Posts tab.
+38 -18
View File
@@ -37,24 +37,27 @@ published; what matters from them is in this file and docs/youtube.md).
- [x] ~~**Most Replayed graph on the seek bar**~~ DONE (v0.41.0: a soft curve over the seek
bar while it's pointed at or dragged, as on YouTube)
- ~~Code: `youtubeContent.mostReplayed()`~~
- [ ] **Movies in Up Next** (seen by the maintainer in 0.66.1, signed in: a free movie rated
PG-13 in Recommended). Two parts:
- **Fix:** its card's grey line shows the movie's price ("Free") and rating ("PG-13")
where a video shows its views and upload date: YouTube's movie items put those in the
fields our parser reads as views / age. Read movies as their own kind and show what
fits (e.g. the year, the rating, the price or "Free"), never price / rating in the
views slot.
- **Add:** a **Movie tag** on the card, beside the 2D / 180° / 360° tag (`cards.js`
kinds): the Material Symbols **`movie`** icon (already in `symbols.js`) instead of
text, in **yellow** (the maintainer's choice), so movies stand out from regular videos.
- Code: the Up Next items (`youtubePage` recommendations, the card fields in
`provider/cards.js`); check Browse and Search results show movies the same way.
- [ ] **Premium features** (the maintainer, after 0.78.4): YouTube Premium's extras in VR, marked with
Material Symbols' **Diamond** icon: **purple** when you don't have Premium (an upsell, like
YouTube's own), **YouTube Red** when you do. To include: the Premium quality levels
("1080p Premium" in the Quality menu: check they show and work), **Add to queue**, and
Premium's other features (to list from the watch page: e.g. Jump ahead, downloads where
they make sense in VR). Find out first how the page says whether you have Premium.
YouTube's own), **YouTube Red** when you do. Find out first how the page says whether you
have Premium (a probe signed in with Premium).
- **Audit** (2026-10-17, from YouTube's help article "YouTube Premium benefits", answer
6308116, computer and Android versions):
| Benefit | On desktop web? | In StreamXR |
|---|---|---|
| **1080p Premium** (enhanced bitrate, 1080p uploads only) | yes, an extra Quality entry | **Done (0.91.0, works in the headset):** its own Quality row with the Diamond (red with Premium; purple and greyed out without) |
| **Jump ahead** (to the point most viewers skip to) | yes (double-click, Ctrl + →) | **Done (0.92.3, works in the headset):** a button at the seek bar's end (Skip ad's spot) with the Diamond, shown while YouTube's own pill is up; clicking it clicks that pill |
| Ad-free videos | yes | Already: no ads means nothing to skip |
| Continue watching across devices | yes (account) | Already: YouTube's player resumes |
| Queue | for everyone on desktop (Premium only on phones) | Not Premium here: its own item (Add to queue, below) |
| Downloads (offline) | desktop has a Download button | Deferred: watching in VR needs the PC online anyway |
| Background play, picture-in-picture, Smart downloads, On-the-go mode, Auto speed | phones only | Out |
| Higher-quality audio (256 kbps music) | apps and TVs only | Out |
| YouTube Music app, Premium badges, Co-Watch with Meet, Afterparties, smart devices | other apps | Out |
| Early access to new features (experiments) | varies | Out: they come and go |
- [x] **Add to queue** (0.100.0 to 0.101.7, works in the headset: a button on cards, a Queue tab in Up Next with Clear and Save (to a new or existing playlist); desktop: for everyone, not Premium): queue a video from a card and
play the queue in order. Find out first how the page's queue can be driven.
## Medium: new panels with scrolling or long text
@@ -63,7 +66,7 @@ published; what matters from them is in this file and docs/youtube.md).
- ~~Code: `youtube.captions()`, `youtube.setCaptions(track)`, `youtube.captionLines()`~~
- Note: YouTube draws captions on the page, not into the video picture, so VR never sees
them; StreamXR reads the lines as they appear (captions-overlay.js draws them).
- [ ] **End screen**
- [x] **End screen** (0.98.0 to 0.98.2, works in the headset: an End screen tab in Up Next when it starts, panels coming back for it; with the autoplay countdown as a card above Autoplay)
- Code: `youtubeContent.endScreen()`
- VR idea: show the suggested videos and channel links when a video ends.
- [x] ~~**Description panel**~~ DONE (v0.29.0: "Description" tab: views · date, the text
@@ -72,7 +75,9 @@ published; what matters from them is in this file and docs/youtube.md).
- [x] ~~**Transcript panel**~~ DONE (v0.29.0: "Transcript" tab when the video has one;
loading spinner, current line highlighted and followed, click a line to seek)
- ~~Code: `youtubeContent.transcript()`, `youtubeContent.hasTranscript()`~~
- [ ] **Info cards**
- [x] **Info cards** (0.97.0 to 0.97.4, work in the headset: the menu with thumbnails, the clickable teaser at the right time, greying,
the panels coming back for a teaser: a Cards button on the controls panel,
a menu of the video's cards, and each card's teaser above the button as its time comes)
- Code: `youtubeContent.cards()`
- VR idea: show the "i" cards (linked videos/playlists) at their times.
@@ -127,6 +132,21 @@ published; what matters from them is in this file and docs/youtube.md).
Left out for now by the maintainer; each can come back if there is demand for it.
- **Movies in Up Next** (the maintainer, 2026-10-18: deferred until YouTube allows its movies
in VR). Why: YouTube's movies are copy-protected, the free ones too (`docs/youtube.md`,
"Movies"), so they can't play in VR; polishing their cards would only lead to a movie that
won't play. The plan, for when it comes back (seen by the maintainer in 0.66.1, signed in: a free movie rated
PG-13 in Recommended). Two parts:
- **Fix:** its card's grey line shows the movie's price ("Free") and rating ("PG-13")
where a video shows its views and upload date: YouTube's movie items put those in the
fields our parser reads as views / age. Read movies as their own kind and show what
fits (e.g. the year, the rating, the price or "Free"), never price / rating in the
views slot.
- **Add:** a **Movie tag** on the card, beside the 2D / 180° / 360° tag (`cards.js`
kinds): the Material Symbols **`movie`** icon (already in `symbols.js`) instead of
text, in **yellow** (the maintainer's choice), so movies stand out from regular videos.
- Code: the Up Next items (`youtubePage` recommendations, the card fields in
`provider/cards.js`); check Browse and Search results show movies the same way.
- **Other viewers' floating reactions in the live chat** (medium; the maintainer, 2026-10-17)
- What: the emojis that float up over YouTube's chat when viewers press the reaction
buttons.
+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"). This is about what YouTube writes;
(the "All" chip, "Joined", an ad's "1 of 2", the Jump ahead pill). This is about what YouTube writes;
StreamXR's own words follow the interface language (Settings → Language).
- Replies to replies (YouTube's newer nested comment threads) aren't shown; one level is.
+108 -20
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.88.1.
Last updated for 0.101.7.
## Top priority: before the first release
@@ -49,43 +49,131 @@ until another headset or two has been tried and the unverified items are cleared
## The public roadmap (README, after release, in this order)
1. Immediate bug fixes (what the first users run into).
2. Chrome Web Store release (one-click install, automatic updates, still free; needs the
store's privacy questions answered: the README's "Your privacy" section covers them).
3. YouTube Premium features (below: the Diamond icon, Premium qualities, Add to queue ...).
4. Explore page tabs (the "Coming Soon" ones).
2. Done: Add to queue (0.100.0 to 0.101.7; free on desktop, not Premium; how the page's queue is driven:
`features/video-page.md`).
3. Explore page tabs: Live, Gaming, Music, Hype, Courses, Podcasts and Memberships done (0.94.0 to
0.96.6); Shorts and Movies & TV are next.
4. Done: Live chat (0.79.0 to 0.88.1), YouTube Premium (1080p Premium 0.91.0, Jump ahead 0.92.3).
5. Commenting in-headset (writing comments and replies with the VR keyboard).
6. Twitch.tv support (the first site after YouTube: a new provider, `src/sites/twitch/`).
7. Groundwork for DRM-protected videos (YouTube Movies, HBO Max and Netflix first, then Disney+, Prime Video): providers and
player ready for when browsers and the services allow protected video in WebXR (e.g. media
layers); the README asks users to tell the services they want desktop XR support.
8. Whatever else comes up.
6. Chrome Web Store release as **1.0** (the maintainer, 2026-10-17: once the YouTube features are
all in; one-click install, automatic updates, still free; needs the store's privacy
questions answered: the README's "Your privacy" section covers them).
7. Twitch.tv support (the first site after YouTube: a new provider, `src/sites/twitch/`; reuses
the live chat panel).
8. Jellyfin and Plex support (new providers, `src/sites/jellyfin/`, `src/sites/plex/`: no DRM on
your own media). Self-hosted servers live at any address, so each needs host access to the
user's server: an optional permission asked when it's turned on (the maintainer agrees on
any permission first: AGENTS.md).
9. Music streaming, Spotify first (its audio is DRM-protected, but sound plays as on the page;
VR shows the player's controls, art and queue, no video to draw). Needs open.spotify.com's
host permission (to agree on first).
10. Barebones mode, **experimental** (the maintainer, 2026-10-17: kept out of 1.0 and after the
full providers, since how well it works depends on each site and first impressions
matter): any `<video>` on any site on the cinema screen with basic controls (play, seek,
volume, format), a generic provider for sites without one. Likely shape: `activeTab` +
`scripting` (it runs only on a page where you click StreamXR's button; nothing added to
the install warning), to agree on first. Unprotected video only (DRM: see 11), and videos
served cross-origin without CORS can't be drawn in WebGL, so many sites will refuse.
11. Groundwork for DRM-protected videos (YouTube Movies, HBO Max and Netflix first, then Disney+, Prime Video): providers and
player ready for when browsers and the services allow protected video in WebXR (e.g. media
layers); the README asks users to tell the services they want desktop XR support.
12. Whatever else comes up.
**As soon as it's possible** (README's second roadmap, ahead of everything above once the support exists):
YouTube Movies (copy-protected: once YouTube allows protected video in desktop XR; showing
them in Up Next with price / rating can come before),
YouTube Movies (copy-protected, the free ones too: once YouTube allows protected video in desktop XR;
their Up Next cards (price / rating, the Movie tag) come with them: `features/video-page.md`, "Deferred"),
Firefox (desktop WebXR), full Steam Frame controller support (Chrome recognizing the
controllers instead of "Oculus Touch"), native Steam Frame support (StreamXR in the Frame's own
Chromium browser, no PC, once that browser supports WebXR), streaming services (HBO Max and
Netflix first) once they allow protected video in desktop XR.
## Road to 1.0 (easiest first)
The roadmap's items before the Chrome Web Store release and the smaller open items, in one
list (the maintainer, 2026-10-17): the working plan. The README's roadmap keeps its own order
(what users see). Details in the sections below and `features/`.
1. [x] **Cubemap seams** (0.93.4, works in the headset; reported by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack) on Reddit and [@MinuteHistoryX](https://x.com/MinuteHistoryX) on X): the glass look's mipmaps (confirmed: gone with the glass off);
the cubemap is now read at full resolution (see "Next" below).
2. [x] **Explore's "Coming Soon" tabs, the ready ones** (0.94.0 to 0.95.6, work in the headset): Live, Gaming, Music,
Hype, Courses, Podcasts, Memberships, each with its sections as pills
(`features/home-page.md`).
3. [x] **A playlist's page before playing it** (0.96.0 to 0.96.4, works in the headset;
Recommended playlist cards untested): clicking a
playlist card opens it in its panel (Explore, You, Search, Channel, Up Next): details, Play all,
Shuffle, Save, and its videos (`features/home-page.md`).
4. [x] **Info cards** (0.97.0 to 0.97.4, work in the headset; with an Autoplay button): YouTube's cards as they come up during a video
(`features/video-page.md`).
5. [x] **End screen** (0.98.0 to 0.98.2, works in the headset; with the autoplay countdown): the suggested videos and channel links when a video ends
(`features/video-page.md`).
6. [x] **News tabs** (0.94.x, work in the headset): Top stories, Sports ... as pills on Explore's
News tab. Also done along the way: Gaming's game shelves as pills (0.96.5 to 0.96.6).
7. [x] **Follow your head on a 2D video** (0.99.0 to 0.99.2, works in the headset, with Delay / Speed sliders and scrolling Settings tabs; requested by [u/boatOV](https://www.reddit.com/user/boatOV) on Reddit, 2026-10-19):
a setting (on / off) that keeps the cinema screen, and the panels with it, in front of you
as you turn your head, so it never needs recentering. Decided: a lazy follow (35° away for
1 s, then easing round; not while using a panel), the panels coming along, a switch in
Settings → 2D Cinema (off by default), 2D videos only.
8. [x] **Add to queue** (0.100.0 to 0.101.7, works in the headset; Clear and Save 0.101.1 to 0.101.7:
**Clear the queue** and **Save the queue as a playlist**, as YouTube's own Queue box offers,
the maintainer, 2026-10-19): probe how desktop YouTube's queue can be driven, then
a way to add from cards, the queue in Up Next, and what plays when a video ends
(`features/video-page.md`).
9. [ ] **Explore's other tabs** (large): Shorts and Movies & TV need new readers first (Learning deferred)
(`features/home-page.md`).
10. [ ] **Commenting in VR** (large): writing comments and replies with the VR keyboard (posting,
threads, typing into the Comments panel).
11. [ ] **Chrome Web Store release, StreamXR 1.0** (last: it waits on the rest): the store
listing, the privacy questions (the README's "Your privacy" covers them), review.
Set aside: the Back button (0.73.0; comes back on demand). Deferred: Movies in Up Next (YouTube's
movies are copy-protected, so they can't play in VR yet: `features/video-page.md`, "Deferred").
## Next: from u/TehSuperFruitSnack's Reddit feedback (after 0.88.1)
1. [x] **Cubemap seams** (0.93.4, works in the headset; also reported by [@MinuteHistoryX](https://x.com/MinuteHistoryX) on X; the cause: the glass look's mipmaps): thin lines along the cube's edges on 360° cubemap ("MESH")
videos; regular (equirectangular) 360° videos don't have them. Likely cause: the glass look
(on by default) builds mipmaps of the video (`gl-utils.js` `videoMipmaps`), and the
sphere's cubemap lookup (`video-sphere.js` `eacUv`) jumps across the texture where two faces
meet, so the GPU picks the smallest mipmap along those lines. Check first: the seams should
go away with the glass off (Settings → Appearance). Fix: in cubemap mode, sample the video
at full resolution (an explicit level of detail, or gradients from the smooth view direction).
- Test: [SheiKra 8K 360° roller coaster](https://www.youtube.com/watch?v=tR8ZtyhSDYw)
(cubemap, 8K); for comparison, with no seams:
[a regular 360° video](https://www.youtube.com/watch?v=Q7UwGaom0XA).
2. [x] **3D side-by-side videos** (0.89.0, works in the headset; the glass per eye fixed in 0.90.1): some VR180 videos reach desktop with both eyes in
one frame (LEFT | RIGHT), and StreamXR stretches the whole frame over the half sphere. Add a
side-by-side format to the Format menu that gives each eye its half (`video-sphere.js`
`uUvXform`, the per-eye hook); picked by hand, since YouTube's data doesn't mark these
videos (auto-detecting by comparing the frame's halves could come later).
- Test: [VR180 Left/Right](https://www.youtube.com/watch?v=ccU0imlNp5Q) (side by side:
LEFT in the left eye, RIGHT in the right). For comparison, one eye only, which stays flat:
[Left Right Test in VR 180](https://youtu.be/kle719aQvsc?t=8).
- Not planned: 3D for videos that reach desktop as one eye, and more than 4K where desktop
YouTube caps it (e.g. [this VR180 tour](https://www.youtube.com/watch?v=KJ-_LUrGhYE)):
desktop YouTube limits ([decisions/0002](decisions/0002-site-player-only.md)).
3. [x] **3D side by side on the cinema screen** (0.93.0, works in the headset): the same for
regular (not VR180) 3D videos, each eye its half on the cinema screen.
- Test: [Demo 3D[SBS] (HD : 1080P)](https://www.youtube.com/watch?v=u0hmMt5znX0) (half side
by side: a 16:9 frame, each eye squeezed into half).
## Features to build (easiest first)
- [ ] **Movies in Up Next**: the price / rating in place of views and date, a yellow Movie tag
(`features/video-page.md`).
- [ ] **Premium features**: the Premium quality levels, Add to queue and the rest, marked with the
Diamond icon (purple without Premium, YouTube Red with it) (`features/video-page.md`).
- [x] **Premium features** (audited 2026-10-17, `features/video-page.md`): **1080p Premium** in the
Quality menu and **Jump ahead**, marked with the Diamond (purple without Premium, YouTube Red
with it). 1080p Premium (0.91.0) and Jump ahead (0.92.3) work in the headset. **Add to queue** is its own item
(not Premium on desktop).
- [ ] **Explore's "Coming Soon" tabs**: Live, Gaming, Music, Hype, Courses, Podcasts,
Memberships (readers exist); Shorts, Movies & TV, Learning need more first
Memberships built in 0.94.0 (to check); Shorts, Movies & TV, Learning need more first
(`features/home-page.md`).
- [ ] **News**: its own tabs (Top stories, Sports ...) and the Sports feed.
- [ ] **Subscriptions**: shelves and posts in the feed, the list of your channels, the bell per
channel (`features/subscriptions-page.md`).
- [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 (desktop has none) and its shelves deferred (`features/subscriptions-page.md`).
- [ ] **Live chat**: reading (0.79.1) and reactions (0.80.0) work in the headset; typing, the
keyboard's emoji pages and Support on desktop built in 0.81.0, Top fans (XP, the list, rank
pills) in 0.82.0 and chat replays in 0.87.0 (all work in the headset). Other viewers' floating
reactions are deferred (`features/video-page.md`, "Deferred"). Left: the unverified items in `known-issues.md`, which the
README's "How you can help" asks users to check (`features/video-page.md`).
- [ ] **End screen**, **Info cards** (`features/video-page.md`).
- [x] **End screen** (0.98.0 to 0.98.2) and **Info cards** (0.97.0 to 0.97.4) done (`features/video-page.md`).
- [ ] **Back button**: set aside (0.73.0); comes back on demand.
## Known quirks (`known-issues.md`)
+211 -4
View File
@@ -19,6 +19,9 @@ backlogs in [features/](features/)):
- `youtube-player.js` (`ns.youtube`): everything on the `#movie_player` API.
- `youtube-page.js` (`ns.youtubePage`): Up Next (merged data + DOM scraping, load more,
playlists), the controls panel's details, Subscribe / Like / Dislike, in-page navigation.
In-page navigation is a `yt-navigate` event with a watch endpoint, sent from
`ytd-page-manager`: sent from `ytd-app` itself, YouTube ignored it on a channel page (it
worked on the watch page and Home), checked 2026-10-17.
- `youtube-content.js` (`ns.youtubeContent`): metadata, description, owner, chapters,
heatmap, end screen, cards, playlist, comments / replies, transcript, search.
- `youtube-channel.js` (`ns.youtubeChannel`): any channel by id (`currentChannelId()` for the
@@ -36,7 +39,15 @@ backlogs in [features/](features/)):
Manage list `subscribed` is always true; one channel had only a stub `subscriptionButton`
and no bell), subscribe / unsubscribe / `setNotifications` (they report ok only when
YouTube's answer shows the new state). Filter chips: none on desktop for the tested
account (the documented ones seem app-only).
account (the documented ones seem app-only). **No community posts** in the desktop feed
(signed in, 2026-10-17: no post data in `FEsubscriptions` at all). The Manage page
(`FEchannels`, seen in the signed-in page's `ytInitialData`, 2026-10-17): the list is split
into **titled shelves** (`shelfRenderer` inside the item section: "Purchased" for channels
you're a member of, then "Subscribed"), and its **sort dropdown** (Most relevant, New
activity, A-Z) is a set of `listItemViewModel`s with `entityKey`
`channel_list_page_sort_chip_entity:<name>`, each tap updating a `chipEntity` (the
dropdown's label) and sending a browse `continuationCommand` that reloads the list in that
order (`sortsOf`, `channelsOn`).
- `youtube-chat.js` (`ns.youtubeChat`): a live stream's chat ("Live chat" below): `info()`
(its views), `open(token)` (a view's chat page), `poll(next)`, and the chat items as plain
messages and changes (add, remove, banner).
@@ -74,6 +85,93 @@ Channel, Home, Subscriptions); what matters from them is in this file and the ba
matched "backstagePostThreadRenderer" and silently dropped every channel post.
- Text matching in `youtube-page.js` assumes **English** YouTube.
- **Explore pages' "View all"** (2026-10-18, signed out): Live's shelves (`richShelfRenderer`)
carry an `endpoint` to a page of their own (a channel's live tab, with continuations); Hype's
(`shelfRenderer`) have it in the shelf's `menu`, a browse to `VL<playlist id>` (Top 100: a
101-video playlist). `youtube-home.js` `viewAllOf` / `viewAll`. News's tabs (Top stories,
Sports, Entertainment, Business, Technology, World, National, Health) are the page's
`twoColumnBrowseResultsRenderer` tabs, each with its own browse params.
- **Mixes' art can't be drawn:** a mix's lockup (`LOCKUP_CONTENT_TYPE_STATION`, e.g. Music's
`RDCLAK5uy_...` mixes) names no first video, and its square art is on `i9.ytimg.com`, which
refuses CORS (as do `i.ytimg.com/s_p/...` variants), so the canvas couldn't be uploaded to
WebGL. Its first video comes from the `next` request (`currentVideoEndpoint`), and that
video's `i.ytimg.com/vi/<id>/` thumbnail loads (`youtube-data.js` `playlistThumb`).
- **Podcasts' covers** (2026-10-18): a podcast lockup (`LOCKUP_CONTENT_TYPE_PODCAST`) carries
its square art in `contentImage` (`i.ytimg.com/pl_c/<id>/studio_square_thumbnail…`, up to
720×720, CORS allowed), used instead of its first episode's frame (`square: true`).
- **Memberships page** (`FEchannel_memberships`, 2026-10-18, signed out): the creators to join
are `horizontalShelfViewModel`s (title in `header.sectionHeaderViewModel.headline`) of
lockups of `LOCKUP_CONTENT_TYPE_TV_NETWORK`: a banner (`contentImage`), the avatar
(`metadata…image.decoratedAvatarViewModel`), the name and the monthly price ("$9.99") as the
metadata row. No channel id field: signed out the tap is a sign-in whose `surlp` parameter
base64-decodes to the channel's `UC…` id; signed in, it's a `ypcGetOffersEndpoint` (`ypc/get_offers`)
whose `params` hold the id the same way (`youtube-data.js` `fromCreator` tries every encoded
parameter). The page puts several shelves in one item section (`youtube-home.js` `shelvesIn`). Members-only
videos carry a `badgeViewModel` with `badgeStyle: "BADGE_MEMBERS_ONLY"` ("Members only").
- **A channel's membership offer** (the "Join" popup; signed in, 2026-10-18): a
`ypcGetOffersEndpoint.params` (on a members-only video's watch page, its Join button; on
the Memberships page, a creator card's tap; starts "sku-") goes to `ypc/get_offers` as
**`itemParams`** (as `params` it answers HTTP 400). The answer's
`actions[0].openPopupAction.popup.sponsorshipsOfferRenderer`: `header.sponsorshipsHeaderRenderer`
(`channelImage`, `superTitle` the channel's name, `title` "Join this channel", `subtitle`
"Get access to membership perks"), `alert.sponsorshipsAlertRenderer.alertMessage` ("To
unlock this video and more, choose a level that's at least $2.99/month."), `tiers[]` of
`sponsorshipsTierRenderer` (`title` "Friends", `subtitle` "$2.99" + "/month", `disclaimer`,
`perks.sponsorshipsPerksRenderer.perks[]` of `sponsorshipsPerkRenderer`: `title`, optional
`description`, optional `images` (badges, emoji), and `purchaseButton` "Join", a
`ypcGetCartEndpoint` (the checkout: StreamXR never calls it)), `bottomDisclaimer`. A
blocked video's `playabilityStatus.errorScreen.playerLegacyDesktopYpcOfferRenderer` has only
`itemTitle` "Members-only content", `itemThumbnail`, `offerDescription`, `offerId`. The
watch page marks a members-only video in `videoPrimaryInfoRenderer.badges`: a
`metadataBadgeRenderer` with style `BADGE_STYLE_TYPE_MEMBERS_ONLY`, label "Members only"
(the recommendations carry such badges for other videos too). Right after switching videos
the player still holds the previous video's data (for a members-only one, not naming its
video), so the members-only state also needs that badge (`provider.js` `watchMembersOnly`).
- **A playlist's own page** (browse `VL<id>`, signed in, 2026-10-18; read by
`youtube-content.js` `playlistDetails()`): the details sit in
`sidebar.playlistSidebarRenderer`: `playlistSidebarPrimaryInfoRenderer` (`title`, `stats[]`
("24 videos", "1,234 views", "Updated …"), `description`, `navigationEndpoint.watchEndpoint`
= Play all's first video, and `menu.menuRenderer.topLevelButtons`: a `toggleButtonRenderer`
**Save playlist / Remove from Library**, and a `buttonRenderer` with icon `SHUFFLE` "Shuffle
play") and `playlistSidebarSecondaryInfoRenderer.videoOwner.videoOwnerRenderer` (the author).
The header (`pageHeaderViewModel`) repeats them as view models. **Save** is `like/like` with
`{ target: { playlistId } }` (`likeEndpoint` status LIKE), **remove** `like/removelike`
(status INDIFFERENT). Whether it's saved: the toggle's `isToggled` (true / false; the
header's `toggleButtonViewModel.isToggled` agrees; checked saved and unsaved). **Shuffle** is a
`watchEndpoint` { videoId (a random one), playlistId, `params` (e.g. "CKMLIAk%3D", different on
each load) }. Opened with those params (`youtube-page.js` `openVideo(id, list, index,
params)`), only the first video was random: the rest played in order. So StreamXR then
turns on the watch page's playlist Shuffle toggle (`shuffleWhenReady`; found by its
"Shuffle" aria-label in `ytd-playlist-panel-renderer`, aria-pressed: works, 0.96.1). With
shuffle on, the player's `getPlaylist()` stays in order, and YouTube's own Next preview shows
the in-order video too: the random next one isn't known until it plays, so StreamXR's Next
card says "A random video from this playlist" while shuffled (0.96.4, works in the headset).
- **A channel you're a member of** (signed in, 2026-10-18): its header has
no `ypcGetOffersEndpoint` (no Join button) and no "join"/"perks" text at all, but the tab
list has a **"Membership"** tab (params `EgptZW1iZXJzaGlw8gYECgIiAA==`). For a member, that
tab's `sectionListRenderer` starts with a `sponsorshipsHubViewModel` (`tierName` (the level's name),
`membershipType` "Member", `membershipDurationOrExpiry` (e.g. "3 months"), `perksTitle` "Your
perks", `badgeImage` (the loyalty badge), `avatar`, a ⋮ sheet of help links), and
`perkCards[]` of `sponsorshipsHubPerkCardViewModel` (`title`, `icon.iconType`). Each card's
`onTap` opens an engagement panel holding the same `sponsorshipsPerksListViewModel`, whose
`perks[]` have `title`, `description.content`, `icon.iconType`, and optionally `emoji[]`
(custom emoji images) or `badges[]` (`title` "Month 6", `image`) with `currentBadgeIndex`.
Then an item section with a `shelfRenderer` "Highlighted content for members" (video
lockups), and one with `backstagePostThreadRenderer`s "Recent members-only posts"
(`sponsorsOnlyBadgeRenderer`). Read by `youtube-channel.js` `membership()`.
The header's `frameworkUpdates` held one `subscriptionStateEntity`
(`subscribed: true`). The tab list also had **"Podcasts"** (params `Eghwb2RjYXN0c_IGBQoDugEA`).
- **Gaming page** (`UCOpNcN46UbXVtpKMrmU4Abg`, the same browse id as youtube.com/gaming;
signed out, 2026-10-18): a "Featured" shelf, a `horizontalCardListRenderer` of
`gameCardRenderer`s ("Top live games": games, not videos) and no continuation. Signed in
(2026-10-19): every entry is an `itemSectionRenderer`; after "Featured" (a `shelfRenderer`),
a game-card list, "Recommended" (a `shelfRenderer` grid) and a Shorts `reelShelfRenderer`
come "YouTube Playables" (`miniGameCardViewModel`s) and then the **game shelves**: each a
`horizontalCardListRenderer` with its title in `header.richListHeaderRenderer` (the game's
name: "Fortnite", "Red Dead Redemption 2" ...; some are "Collaborators") and its videos as
`videoCardRenderer`s, about 14 on the first page and more on the continuation. Read since
0.96.5 (`youtube-home.js` SHELF_KEYS, `youtube-channel.js` collect; videos, channel, views
and age work in the headset); the header's "View all" link is UNVERIFIED.
## Subscribe and Like
- The watch page's subscribe box holds both buttons ("Subscribe" and "Subscribed"), so its
@@ -96,10 +194,35 @@ The formats are in `src/streamxr/shared/formats.js`; YouTube's detection is the
from `getAvailableQualityData()` (`qualityLabel`, e.g. "1080p60", or "2160s60" for a 360° /
VR180 video: "s" for spherical) where the player has it
(`youtube-player.js` `quality().labels`); with Premium it also lists "1080p Premium",
skipped in favour of the plain entry. Unverified in the headset (see known-issues.md).
kept apart as its own level (0.91.0). Seen signed in with Premium (2026-10-17): the entry
has `formatId` "721", `quality` "hd1080", `paygatedQualityDetails.paygatedIndicatorText`
"Enhanced bitrate", `isPlayable` true, beside the plain 1080p (`formatId` "399"); it's
chosen with `setPlaybackQualityRange(q, q, formatId)` and `getPlaybackQualityLabel()` names
the one playing. **Whether you have Premium:** the top bar's logo,
`ytInitialData.topbar.desktopTopbarRenderer.logo.topbarLogoRenderer.iconImage.iconType`
is `YOUTUBE_PREMIUM_LOGO` (`youtube-player.js` `hasPremium`). **Jump ahead:** the page has
no data for it, and the player's `prefetchJumpAhead()` returns undefined and moves nothing.
YouTube shows a **Jump ahead pill** in the player's "timely actions" overlay at the moments
it applies (`.ytp-timely-actions-overlay ytw-timely-action-view-model button`, its text
"Jump ahead"), and a script's `click()` on it jumps (17 s -> 124 s, 2026-10-17), so
`youtube-player.js` `jumpAhead()` clicks it and `jumpAheadOffered()` tells whether it's up.
A script-sent Ctrl + → keydown on `#movie_player` jumped from the console but did nothing
from VR.
- Desktop YouTube sends **one eye only**, even for 3D videos, so there are no 3D formats.
- Formats: `flat` (cinema screen), `360mono`, `360eac`, `180mono`.
- Desktop YouTube sends **one eye only** for most 3D videos (checked 2026-10-17 with frames
from the page's `<video>`): the "Left Right Test in VR 180" (`kle719aQvsc`, 1920×2160) and
a 3D VR180 tour (`KJ-_LUrGhYE`, 3840×2160, 2160s at most on desktop) hold the left eye
only. **Some arrive side by side** (both eyes in one frame): "VR180 Left/Right"
(`ccU0imlNp5Q`, 3840×2160, LEFT | RIGHT). Nothing in the player response tells them apart
(all `MESH`, `vrConfig.partialSpherical`, no stereo field). The Quest app gets both eyes
and higher resolutions, most likely as a VR client; StreamXR doesn't bypass the page's
player to get them ([decisions/0002](decisions/0002-site-player-only.md)).
- 8K reaches desktop where YouTube offers it, e.g. an 8K 360° video (`tR8ZtyhSDYw`):
`4320s`, 7680×3840, AV1 (itag 571).
- Formats: `flat` (cinema screen), `flatsbs` (the cinema screen in 3D side by side: 0.93.0;
e.g. "Demo 3D[SBS]", `u0hmMt5znX0`, a RECTANGULAR 16:9 frame, each eye squeezed into half),
`360mono`, `360eac`, `180mono`, `180sbs` (3D side by side,
picked by hand: 0.89.0).
- "MESH" 360° videos use an **equi-angular cubemap**, 3×2: top row left | front | right,
bottom row down | back | up; down and up rotated 270° clockwise, back 90° (the same as
ffmpeg's `eac`). Verified against a real frame.
@@ -114,6 +237,19 @@ The formats are in `src/streamxr/shared/formats.js`; YouTube's detection is the
- VR180 test video: "how TV screens made watching movies worse" by Noodle (`wlUV6y5TUko`):
no "180" in the title, 16:9 below 2160p.
## Movies
- **YouTube's movies are copy-protected, the free ones too** (checked 2026-10-18, signed out:
"Matilda", `_F8kU1vb_rk`, "Free with ads", PG): playable on the page, but every format
carries `drmFamilies` (WIDEVINE, PLAYREADY, FAIRPLAY) and `streamingData.drmParams`, so
StreamXR can't draw them in VR (as Netflix: ARCHITECTURE.md "Known limits"). An R-rated one
("Life", `SosdXyFcL7c`) asks signed-out viewers to sign in to confirm their age.
- In search results a movie is a `movieRenderer` with `badges` of
`metadataBadgeRenderer`s: the price as style `BADGE_STYLE_TYPE_YPC` ("Free with ads", or a
price), the rating and "CC" as `BADGE_STYLE_TYPE_MEDIA` ("PG", "TV-G", "R", "Unrated");
`longBylineText` "YouTube Movies & TV", a `movieposter_en.webp` thumbnail (portrait). Up
Next's movie items (seen signed in, 0.66.1) weren't captured yet.
## Ads
Test ad handling in a browser without YouTube Premium (e.g. signed out); ads are rationed,
@@ -332,6 +468,77 @@ snippets); read by `youtube-chat.js` since 0.79.0:
So `mostReplayed()` reads the page's data and otherwise asks `next` once per video
(checked Oct 2026 on a very popular music video). Newer or less watched videos have none.
## The queue (Add to queue)
- A card's ⋮ menu item "Add to queue" is a client-side command (2026-10-19, signed in):
`signalServiceEndpoint` { signal CLIENT_SIGNAL, actions: [{ `addToPlaylistCommand`: {
`openMiniplayer`, `videoId`, `videoIds`, `listType: PLAYLIST_EDIT_LIST_TYPE_QUEUE`,
`onCreateListCommand` (a `createPlaylistServiceEndpoint` { videoIds, params "CAQ%3D" }:
`playlist/create`, for the first one) and `videoCommand` (its watchEndpoint) } }] }.
StreamXR builds the same command and hands it to the page's own command handler,
`document.querySelector('ytd-app').handleCommandWithCommandHandler(cmd)` (works; a
`yt-action` event with `yt-execute-innertube-command` did nothing). `youtube-page.js` `addToQueue()`.
- The queue then shows where a playlist does: a `ytd-playlist-panel-renderer` titled "Queue",
list id `TLPQ…`, its items in the element's own `data` (not in the watch page's data, nor on
a `VL…` page); the player's `getPlaylist()` lists them too (`getPlaylistId()` is null). When
the current video ends the first queued one plays, and the finished one leaves the queue.
`youtube-content.js` `playlist()` reads the panel's data for it (`queue: true`).
- The Queue box's data (2026-10-20) has `menu.menuRenderer.topLevelButtons[0]`: **Clear**
("Clear queue"), a `signalServiceEndpoint` of an `endPlaylistCommand` { closeListPanel,
listId (the queue), listType QUEUE } and a "Queue cleared" toast with Undo (a
`recoverPlaylistCommand`); StreamXR runs that command as is (`queueClearCommand()`,
`youtube-page.js` `runCommand()`). And `playlistButtons`: **Save**, a `showSheetCommand` for
the "Save to playlist" sheet (`PAadd_to_playlist`, params naming the queue); StreamXR asks
`playlist/get_add_to_playlist` with `{ playlistId: <the queue> }` instead (with several
`videoIds` it answers HTTP 400; one video, or the list id, work), adds them with
`browse/edit_playlist` (ACTION_ADD_VIDEO each), and makes a new one with `playlist/create`
{ title, videoIds, privacyStatus PRIVATE } (works: seen 0.101.1, the playlist made with the queued videos).
- After Clear (2026-10-23), the watch page's own data (`twoColumnWatchNextResults.playlist`)
still holds the queue until the next video, and the Queue box keeps its data without its
menu: a cleared queue's id is ignored in both places. The queue's id is `TLPQ` + the date
(base64, e.g. "23102026") + a random part.
- The same video queued twice is in both the page's and the player's lists twice, at the
same positions; either copy plays from Up Next.
- Just after adding to the queue, the player's `getPlaylistIndex()` can sometimes be one
ahead of the video playing (seen 0.101.6); StreamXR takes the
position from the video the tab's URL is on (the nearest copy of it in `getPlaylist()`; 0.101.7, works in the headset).
## End screen
- In the player's data: `playerResponse.endscreen.endscreenRenderer` (`startMs`, `elements[]`
of `endscreenElementRenderer`: `style` VIDEO / PLAYLIST / CHANNEL / WEBSITE ..., `title`,
`metadata` (a video's views; a channel's description), `image`, `startMs` / `endMs`, its
place on the video (`left`, `top`, `width`, `aspectRatio`), `endpoint`; a channel tile also
`isSubscribe`, `callToAction`, `hovercardButton`). Probed 2026-10-19 (a video with a channel
tile for the video's own channel and two video tiles, 1231.6 s to the end), read as is by
`youtube-content.js` `endScreen()`. Shown since 0.98.0 as Up Next's End screen tab.
- **Autoplay's countdown** ("Up next in 5" at a video's end): read from the player's own overlay,
`.ytp-autonav-endscreen-countdown-overlay` (the next video's link, `.ytp-autonav-endscreen-upnext-title`
/ `-author`, the seconds in `.ytp-autonav-endscreen-countdown`), cancelled with its
`.ytp-autonav-endscreen-upnext-cancel-button` (`youtube-player.js` `autoplayCountdown()`,
`cancelAutoplay()`; works, 0.98.2).
## Info cards
- The player's data has a card list, `playerResponse.cards.cardCollectionRenderer.cards[]`,
each a `cardRenderer` with `teaser.simpleCardTeaserRenderer.message` (the teaser),
`cueRanges[0].startCardActiveMs` (when it comes) and, in the shape it used to have, `content`
(a video, playlist or link card). StreamXR read it in 0.97.0 (`youtube-content.js` `cards()`,
the Cards button in `extras.js`); in practice it holds no real cards (below).
Probed 2026-10-19 (signed in): on a video **with** cards and one without, that list held
only one card, the same on both: a "View corrections" teaser (`prominent`, its tap opens the
`engagement-panel-error-corrections` panel) with no `content`, cue 0-5 s; the collection's
`headerText` is "From <channel>". It's left out (0.97.1). The video with cards had them in
the watch page's description panel instead: `engagementPanels[].…structuredDescriptionContentRenderer
.items[].videoDescriptionInfocardsSectionRenderer.infocards[].compactInfocardRenderer` (the
video without had the section but no infocards). Each `compactInfocardRenderer.content` is
e.g. a `structuredDescriptionVideoLockupRenderer` (`title`, `shortBylineText` "by …",
`metadataDetails` "56K views · 3 years ago", `lengthText`, `navigationEndpoint.watchEndpoint`).
**No times anywhere**: a teaser showed at 26:18 and neither the description section nor the
player's card list changed (still only the corrections card). So since 0.97.2 the cards are
read from the description section and the teaser's moment from the player's own
`.ytp-cards-teaser` element while it shows (`youtube-player.js` `cardTeaser()`; works, 0.97.2).
## Audio, channels, storyboards
- Audio tracks (checked on OLEG ZAYANOV, `DbKDX-AyJIQ`: "English (US)" auto-dub = default,
+3 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "StreamXR",
"version": "0.88.1",
"version": "0.101.7",
"description": "Watch streaming video in a SteamVR headset: 360° and VR180 videos around you, regular videos on a cinema screen. Supports YouTube.",
"action": {
"default_title": "StreamXR: open YouTube and click the VR button (the headset)",
@@ -61,6 +61,7 @@
"src/core/page/vr-panel/styles/kit.js",
"src/core/page/vr-panel/styles/grid.js",
"src/core/page/vr-panel/styles/thread.js",
"src/core/page/vr-panel/styles/collection.js",
"src/core/page/vr-panel/styles/chat.js",
"src/core/page/vr-panel/styles/queue.js",
"src/core/page/vr-panel/styles/catalog.js",
@@ -101,6 +102,7 @@
"src/sites/youtube/page/provider/badges.js",
"src/sites/youtube/page/provider/cards.js",
"src/sites/youtube/page/provider/comments.js",
"src/sites/youtube/page/provider/playlist.js",
"src/sites/youtube/page/provider/upnext.js",
"src/sites/youtube/page/provider/browse.js",
"src/sites/youtube/page/provider/search.js",
+21 -11
View File
@@ -38,10 +38,14 @@
// drawn at the tint, so alpha / tint is 1 anywhere on the panel, less on its
// antialiased edge, 0 outside it);
// - behind(): the ray from the eye (uEye) through this point, into the backdrop:
// uKind 1 'sphere' (equirectangular, uSpan radians of longitude, turned by uYaw),
// uKind 1 'sphere' (equirectangular, uSpan radians of longitude, turned by uYaw;
// its across coordinate then scaled and shifted by uUvX: (1, 0) for the whole
// frame, (0.5, 0) / (0.5, 0.5) for the left / right eye's half of a side-by-side 3D
// frame, set per eye),
// uKind 2 'eac' (YouTube's cubemap, the same layout as video-sphere.js),
// uKind 3 'screen' (the cinema screen: centre uScrC, axes uScrR / uScrU / uScrN
// (toward you), size uScrSize, bent by uBend radians around a vertical circle);
// (toward you), size uScrSize, bent by uBend radians around a vertical circle;
// sampled at this eye's part of the frame across, uUvX, as above);
// its texture coordinates, or "outside" (the dark void colour, uVoid);
// - the blur: the backdrop texture with a mipmap bias (texture2D's third argument, so
// a much smaller mipmap is read), at the centre and four points around it (uBlur
@@ -59,7 +63,7 @@
uniform sampler2D uTex, uBack;
uniform float uGlass, uTint, uKind, uSpan, uYaw, uBend, uBlur, uAlpha;
uniform vec3 uEye, uScrC, uScrR, uScrU, uScrN, uVoid;
uniform vec2 uScrSize, uTexel;
uniform vec2 uScrSize, uTexel, uUvX;
uniform vec3 uGlow; // the cinema screen's glow: strength (0: none), reach (m), corner radius (m)
varying vec2 vUv; varying vec3 vWorld;
const float PI = 3.14159265;
@@ -93,7 +97,7 @@
vec3 l = vec3(c * d.x - s * d.z, d.y, s * d.x + c * d.z);
if (uKind > 1.5) return vec3(eacUv(l), 1.0);
float lon = atan(l.x, -l.z), pol = acos(clamp(l.y, -1.0, 1.0));
return vec3(lon / uSpan + 0.5, pol / PI, step(abs(lon), uSpan * 0.5));
return vec3((lon / uSpan + 0.5) * uUvX.x + uUvX.y, pol / PI, step(abs(lon), uSpan * 0.5));
}
// The cinema screen, in its own axes.
vec3 e = eye - uScrC;
@@ -132,13 +136,16 @@
vec3 back = uVoid;
if (uKind > 0.5) {
vec3 b = behind(uEye, normalize(vWorld - uEye));
// On the cinema screen, sample this eye's part of the frame across (uUvX; the picture's
// own coordinates, b.xy, stay for the glow's shape).
vec2 sb = uKind > 2.5 ? vec2(clamp(b.x, 0.0, 1.0) * uUvX.x + uUvX.y, b.y) : b.xy;
if (b.z > 0.75) {
const float BIAS = 5.0;
vec3 s = texture2D(uBack, b.xy, BIAS).rgb * 0.4;
s += texture2D(uBack, b.xy + vec2(uBlur, uBlur), BIAS).rgb * 0.15;
s += texture2D(uBack, b.xy + vec2(-uBlur, uBlur), BIAS).rgb * 0.15;
s += texture2D(uBack, b.xy + vec2(uBlur, -uBlur), BIAS).rgb * 0.15;
s += texture2D(uBack, b.xy + vec2(-uBlur, -uBlur), BIAS).rgb * 0.15;
vec3 s = texture2D(uBack, sb, BIAS).rgb * 0.4;
s += texture2D(uBack, sb + vec2(uBlur, uBlur), BIAS).rgb * 0.15;
s += texture2D(uBack, sb + vec2(-uBlur, uBlur), BIAS).rgb * 0.15;
s += texture2D(uBack, sb + vec2(uBlur, -uBlur), BIAS).rgb * 0.15;
s += texture2D(uBack, sb + vec2(-uBlur, -uBlur), BIAS).rgb * 0.15;
back = s;
} else if (b.z > 0.25 && uGlow.x > 0.0) {
// Past the screen's edge: its glow (cinema-screen.js GLOW_FS) added to the dark, the
@@ -149,7 +156,7 @@
vec2 q = abs(p) - uScrSize * 0.5 + uGlow.z;
float dist = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - uGlow.z;
float k = clamp(dist / uGlow.y, 0.0, 1.0), a = uGlow.x * (1.0 - k) * (1.0 - k);
if (a > 0.002) back += texture2D(uBack, b.xy, 6.0).rgb * a;
if (a > 0.002) back += texture2D(uBack, sb, 6.0).rgb * a;
}
}
float grain = fract(sin(dot(floor(vUv / uTexel), vec2(12.9898, 78.233))) * 43758.5453) - 0.5;
@@ -179,7 +186,7 @@
const prog = G.createProgram(gl, VS, FS, 'vr panel');
const U = G.uniforms(gl, prog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', tex: 'uTex', back: 'uBack',
alpha: 'uAlpha', glass: 'uGlass', tint: 'uTint', kind: 'uKind', span: 'uSpan', yaw: 'uYaw', bend: 'uBend', blur: 'uBlur', eye: 'uEye',
scrC: 'uScrC', scrR: 'uScrR', scrU: 'uScrU', scrN: 'uScrN', voidC: 'uVoid', scrSize: 'uScrSize', texel: 'uTexel', glow: 'uGlow' });
scrC: 'uScrC', scrR: 'uScrR', scrU: 'uScrU', scrN: 'uScrN', voidC: 'uVoid', scrSize: 'uScrSize', texel: 'uTexel', glow: 'uGlow', uvX: 'uUvX' });
const vao = vaos.create(); vaos.bind(vao);
// Two triangles making the square (6 corners, x/y pairs).
const buf = G.floatAttribute(gl, prog, 'aCorner', new Float32Array([-0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5]), 2);
@@ -220,6 +227,9 @@
gl.uniform1f(U.blur, BLUR[backdrop.kind] || 0.01);
gl.uniform1f(U.span, backdrop.span || Math.PI * 2);
gl.uniform1f(U.yaw, backdrop.yaw || 0);
// (uvX: one [scale, offset] for both eyes, or { left, right }: each eye its own part)
const ux = backdrop.uvX, eyeX = !ux ? [1, 0] : Array.isArray(ux) ? ux : view.eye === 'right' ? ux.right : ux.left;
gl.uniform2fv(U.uvX, eyeX);
if (backdrop.kind === 'screen') {
gl.uniform3fv(U.scrC, backdrop.centre); gl.uniform3fv(U.scrR, backdrop.right);
gl.uniform3fv(U.scrU, backdrop.up); gl.uniform3fv(U.scrN, backdrop.normal);
+54 -17
View File
@@ -12,6 +12,7 @@
//
// state: { title, time, duration, paused, hover, pressed, scrubTime, grabbing,
// buffered (seconds: how far the video is loaded ahead, shown on the seek bar; or null),
// playable (false: the site won't play this video, Play is greyed out),
// now (ms; a long title scrolls with it),
// nowPlaying: { info: [{ text, color }] (the line under the title, pieces in a row;
// color: an M3 role name, e.g. 'onSurfaceVariant'),
@@ -40,7 +41,11 @@
// "Skip on desktop" without the Skip Helper extension),
// extras: { left: [...], right: [...] } (the site's own buttons, at the inner ends
// of the outer parts of the button row: { id, icon, label (under the
// icon), on (icon filled), disabled }; ids 'x:<id>'),
// icon), on (icon filled), disabled, icons (optional: one or two icons, e.g.
// { name, color } ones, stacked in place of icon + label, like Quality's) },
// and seek: null | { id, label, icon, mark (an icon spec on its left, e.g.
// a coloured one), disabled } (one button at the seek bar's right end, where
// Skip ad goes during ads; the bar shortens for it); ids 'x:<id>'),
// menuOpen: null | 'quality' | 'speed' | 'format' | 'volume' | 'x:<id>' (that button
// is shown selected: its menu floats in front of the panel, popups-ui.js) }
// hover/pressed: a control id or null. Fixed ids: seek, exit, quality, speed, back10,
@@ -54,11 +59,11 @@
const TC = T.color; // (today's colours: the ad colour)
const F = role => T.font(role);
// The panel is one 1216-pixel-wide canvas, laid out in fixed pixel positions (the world
// The panel is one 1408-pixel-wide canvas, laid out in fixed pixel positions (the world
// size follows the panels' pixel density: vr-panel.js). From the top: title row, info line, creator
// row (with the action buttons on its right), seek bar, button row, then the grab bar
// below the body.
const W = 1216, H = 422; // panel body is 0..374; the grab bar sits below it
const W = 1408, H = 422; // panel body is 0..374; the grab bar sits below it (1408: 13 buttons at full width)
const BODY_H = 374;
const ROW_Y = 250; // top of the button row
const ACTIONS = { y: 74, h: 58, gap: 12, joined: 3 }; // right to left; `joined`: inside a connected group
@@ -71,9 +76,11 @@
};
// The button row: one M3E button group, in three parts ([Exit, Quality, Speed, the site's
// left extras] [-10 s, play / pause, +10 s] [the site's right extras, Volume, Format,
// Recenter]), centred (room for one extra a side). Buttons are BTN.w x
// BTN.h, play / pause is bigger; GAP between buttons, SEP between the parts.
// Recenter]), centred (room for one extra a side at full width; with more, the buttons
// narrow to stay inside ROW_MARGIN). Buttons are BTN.w x BTN.h, play / pause is bigger;
// GAP between buttons, SEP between the parts.
const BTN = { w: 88, h: 88, y: ROW_Y + 2 }, PLAY = { w: 132, h: 104, y: ROW_Y - 6 }, GAP = 8, SEP = 20;
const ROW_MARGIN = 48; // the button row keeps this far from the panel's sides
// In a playlist: Previous / Next video buttons at the two ends of the seek row, and the
// seek bar shortened between them.
const PLAYLIST = {
@@ -91,7 +98,7 @@
const tr = (key, vars) => ns.i18n.t(key, vars); // words in the interface language (i18n.js)
// Which icon a format gets on the Format button.
const FORMAT_ICONS = { flat: 'theaters', '360mono': '360', '360eac': 'view_in_ar', '180mono': 'panorama_photosphere' };
const FORMAT_ICONS = { flat: '2d_2', flatsbs: '3d_2', '360mono': '360', '360eac': 'view_in_ar', '180mono': 'panorama_photosphere', '180sbs': '3d_rotation' };
// The speaker for a volume (0-100) / muted.
const volumeIcon = v => (v.muted ? 'volume_off' : v.level <= 0 ? 'volume_mute' : v.level < 50 ? 'volume_down' : 'volume_up');
@@ -229,8 +236,8 @@
// An icon over a small label (the button row's buttons), or the icon alone, centred.
// One icon big, or two stacked (the first smaller and higher, e.g. 4K over 60 fps).
function iconStack(icons) {
if (icons.length < 2) { S.draw(g, icons[0], 0, 0, 48, false); return; }
S.draw(g, icons[0], 0, -14, 38, false); S.draw(g, icons[1], 0, 20, 30, false);
if (icons.length < 2) { S.drawIcon(g, icons[0], 0, 0, 48, false); return; }
S.drawIcon(g, icons[0], 0, -14, 38, false); S.drawIcon(g, icons[1], 0, 20, 30, false);
}
function iconLabel(icon, text, filled = false) {
if (text) { S.draw(g, icon, 0, -13, 36, filled); label(text, 'labelMedium', 23); }
@@ -318,9 +325,6 @@
// The button row (see BTN): each shows its icon and, under it, its current value.
function drawTransport(state) {
const v = state.volume || { level: 100, muted: false };
// The format's short name, first line only (a two-line name would be too small; the
// icon tells the formats apart).
const fmtLabel = ((state.format && state.format.label) || '—').split('\n')[0];
// The site's own buttons (state.extras): [id, icon, label, extra].
const extras = side => ((state.extras && state.extras[side]) || []).map(x => [`x:${x.id}`, x.icon, x.label || '', x]);
const items = [
@@ -330,10 +334,15 @@
['back10', 'replay_10', ''], ['play', state.paused ? 'play_arrow' : 'pause', ''], ['fwd10', 'forward_10', ''], null,
...extras('right'),
['volume', volumeIcon(v), v.muted ? tr('panel.muted') : `${Math.round(v.level)}%`],
['format', FORMAT_ICONS[state.format && state.format.id] || 'theaters', fmtLabel],
['format', formatIcon(state.format && state.format.id), tr('panel.format')], // (the icon tells the formats apart)
['recenter', 'recenter', tr('panel.recenter')],
];
const width = it => (it[0] === 'play' ? PLAY.w : BTN.w);
// Buttons narrow (from BTN.w) when a site's extra buttons would push the row past the
// panel's side margins; heights stay.
const n = items.filter(it => it && it[0] !== 'play').length, seps = items.filter(it => !it).length;
const gaps = (items.length - seps - 1) * GAP + seps * (SEP - GAP);
const bw = Math.min(BTN.w, Math.floor((W - ROW_MARGIN * 2 - PLAY.w - gaps) / Math.max(1, n)));
const width = it => (it[0] === 'play' ? PLAY.w : bw);
const total = items.reduce((s, it) => s + (it ? width(it) + GAP : SEP - GAP), -GAP);
let x = (W - total) / 2;
for (const it of items) {
@@ -341,12 +350,13 @@
const [id, icon, text, extra] = it;
if (id === 'play') {
button({ x, y: PLAY.y, w: PLAY.w, h: PLAY.h }, id, state, () => S.draw(g, icon, 0, 0, 56, true),
{ kind: 'brand', radius: T.shape.extraLargeIncreased });
{ kind: 'brand', radius: T.shape.extraLargeIncreased, disabled: state.playable === false }); // (greyed out: the site won't play this video)
} else {
// the button whose menu is open shows selected (its menu floats in front: popups-ui.js);
// an extra that's `on` (e.g. captions showing) has its icon filled
const qi = id === 'quality' && state.quality && state.quality.icons; // the quality as icons (its full name is in its menu)
button({ x, y: BTN.y, w: BTN.w, h: BTN.h }, id, state, () => (qi && qi.length ? iconStack(qi) : iconLabel(icon, text, !!(extra && extra.on))),
// the quality as icons (its full name is in its menu), or a site button's own icons
const qi = id === 'quality' ? state.quality && state.quality.icons : extra && extra.icons;
button({ x, y: BTN.y, w: bw, h: BTN.h }, id, state, () => (qi && qi.length ? iconStack(qi) : iconLabel(icon, text, !!(extra && extra.on))),
{ kind: state.menuOpen === id ? 'selected' : 'tonal', disabled: !!(extra && extra.disabled) });
}
x += width(it) + GAP;
@@ -408,6 +418,16 @@
regions = [];
const queue = state.nowPlaying && state.nowPlaying.queue;
seekRect = queue && !state.ad ? PLAYLIST.seek : FIXED.seek;
// The site's seek-row button (state.extras.seek, e.g. Jump ahead), where Skip ad goes
// during ads: at the seek bar's right end (before a playlist's next button), the bar
// shortened to make room.
const sa = !state.ad && state.extras && state.extras.seek;
let saRect = null;
if (sa) {
const right = queue ? PLAYLIST.next.x - 16 : seekRect.x + seekRect.w;
saRect = { x: right - FIXED.skipad.w, y: FIXED.skipad.y, w: FIXED.skipad.w, h: FIXED.skipad.h };
seekRect = { ...seekRect, w: saRect.x - 24 - seekRect.x };
}
g.clearRect(0, 0, W, H);
g.fillStyle = bodyFill();
roundRect(g, 0, 0, W, BODY_H, T.shape.extraLarge); g.fill();
@@ -461,6 +481,7 @@
else {
drawSeekBar(state, shown);
if (queue) drawPlaylistButtons(queue, state);
if (sa) drawSeekAction(sa, saRect, state);
}
drawTransport(state); // the button row
@@ -503,6 +524,19 @@
}
}
// The site's seek-row button (id 'x:<id>', like its other extras), shaped like Skip ad:
// its mark (an icon, e.g. the Diamond in its own colour) on the left, the label, its icon
// on the right. Greyed out when disabled.
function drawSeekAction(x, r, state) {
button(r, `x:${x.id}`, state, () => {
const left = x.mark ? 36 : 0;
if (x.mark) S.drawIcon(g, x.mark, -r.w / 2 + 30, 0, 26, true);
const t = ns.vrKit.fitFont(g, x.label || '', r.w - 84 - left, 24, '500 ');
g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(t, -20 + left / 2, 1);
if (x.icon) S.draw(g, x.icon, r.w / 2 - 34, 0, 30, true);
}, { disabled: !!x.disabled });
}
// A live stream, in place of the time (top right): a "● LIVE" chip in the brand colour at
// the live moment. Behind it (rewound, paused, or while scrubbing) the chip turns tonal,
// with how far behind in front ("−0:45"), and clicking it (id 'live') jumps back to the
@@ -624,5 +658,8 @@
titleMoving: () => titleMoving, animating: motion.moving };
}
ns.vrPanelUI = { createPanelUI, formatTime: fmt, speedName };
// The Format button's icon for a format id (also each row's icon in the Format menu: vr-panel.js).
const formatIcon = id => FORMAT_ICONS[id] || '2d_2';
ns.vrPanelUI = { createPanelUI, formatTime: fmt, speedName, formatIcon };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+62 -16
View File
@@ -10,8 +10,11 @@
// 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 drawn
// on the row's left, e.g. ['4k', '60fps']; the tick then moves to the right end) }],
// on (the current one: ticked), disabled, icons (optional: Material Symbols names, or
// { name, color } for one in its own colour (symbols.js drawIcon), drawn
// on the row's left, e.g. ['4k', '60fps']; the tick then moves to the right end), image (a
// picture's url: a 16:9 thumbnail on the row's left, the row taller, e.g. a video), corner
// (text on the picture's bottom-right, e.g. its length) }],
// note (a grey line above the items), scroll
// (a long list scrolls: see drawList), key, width }
// or { volume: { level (0-100), muted } }; ids: the items', or 'vol:mute' / 'vol:slider'
@@ -26,7 +29,9 @@
// const dlg = ns.vrPopupsUI.createDialog();
// dlg.draw(d, { hover, pressed }) d: { title, text (up to four lines: the card grows to fit), cancel (null: no
// cancel button, for a dialog that only informs), confirm, danger (the confirm button in
// the error colour) }; ids 'dialog:cancel', 'dialog:confirm'
// the error colour), field: null | { text, placeholder } (a text field under the text, typed
// into with the keyboard: its text, a caret, a grey placeholder while empty; the confirm
// button greyed while it's empty) }; ids 'dialog:cancel', 'dialog:confirm'
// dlg.hitTest(u, v) -> { id } | null; dlg.solidAt(u, v) (on the card); dlg.moving()
(function (ns) {
'use strict';
@@ -36,17 +41,20 @@
const tr = (key, vars) => ns.i18n.t(key, vars); // words in the interface language (i18n.js)
const inside = (r, x, y) => x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h;
function createMenu() {
// onImageLoaded(): a row's picture arrived (the caller redraws).
function createMenu(onImageLoaded = () => {}) {
const W = 680, H = 760; // room for the widest (volume) and tallest (a long list)
const LIST_W = 360, ROW_H = 64, MIN_ROW_H = 40, PAD = 8, NOTE_H = 48; // rows shrink (to MIN_ROW_H) so a long list fits
const SCROLL_ROWS = 8; // a scrolling list shows this many rows
const ICON = 32, ICON_STEP = 36, ICON_LEFT = 20; // items' leading icons: size, spacing, left margin (px)
const THUMB = { w: 144, h: 81, rowH: 100 }; // items' pictures (16:9, e.g. a video's), and their rows' height
const VOL = { w: 640, h: 112 };
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const g = canvas.getContext('2d');
const motion = ns.vrMotion.create(); // hover / press easing (motion.js)
let regions = [], card = null, slider = null;
const kit = ns.vrKit.createCardKit(g, onImageLoaded); // (pictures: loading and drawing)
let list = null, scroll = 0, maxScroll = 0, listKey = null; // a list's scrolling part, how far scrolled (px), how far it can, its m.key
// The menu's card at r: the panels' glass (T.panelFill: the tint, drawn over the blurred
@@ -79,11 +87,12 @@
function drawList(m, ui) {
const items = m.items || [];
const lead = Math.max(0, ...items.map(it => (it.icons || []).length));
const textX = lead ? ICON_LEFT + lead * ICON_STEP + 12 : 64; // where the names start in a row
const listW = Math.min(W, (m.width || LIST_W) + (lead ? textX - 64 + 40 : 0));
const pics = items.some(it => it.image != null); // rows with pictures (taller)
const textX = pics ? 12 + THUMB.w + 16 : lead ? ICON_LEFT + lead * ICON_STEP + 12 : 64; // where the names start in a row
const listW = Math.min(W, (m.width || LIST_W) + (lead || pics ? textX - 64 + 40 : 0));
const noteH = m.note ? NOTE_H : 0;
const rowH = m.scroll ? ROW_H : Math.max(MIN_ROW_H, Math.min(ROW_H, Math.floor((H - PAD * 2 - noteH) / Math.max(1, items.length))));
const shownRows = m.scroll ? Math.min(items.length, SCROLL_ROWS) : items.length;
const rowH = pics ? THUMB.rowH : m.scroll ? ROW_H : Math.max(MIN_ROW_H, Math.min(ROW_H, Math.floor((H - PAD * 2 - noteH) / Math.max(1, items.length))));
const shownRows = m.scroll ? Math.min(items.length, pics ? 6 : SCROLL_ROWS) : items.length;
const h = PAD * 2 + noteH + shownRows * rowH;
card = { x: (W - listW) / 2, y: H - h, w: listW, h };
cardAt(card);
@@ -96,7 +105,12 @@
list = { x: card.x, y: card.y + PAD + noteH, w: listW, h: shownRows * rowH };
maxScroll = m.scroll ? Math.max(0, items.length * rowH - list.h) : 0;
scroll = Math.min(maxScroll, Math.max(0, scroll));
g.save(); g.beginPath(); g.rect(list.x, list.y, list.w, list.h); g.clip();
// Clipped to the rows plus the card's padding, so the pointing ring round the first and
// last rows (drawn just outside them) isn't cut; a scrolling list's rows can show that far
// past its ends too.
const bleed = PAD;
g.save(); g.beginPath(); g.rect(list.x, list.y - bleed, list.w, list.h + bleed * 2); g.clip();
const rings = []; // [row, radius, amount] to ring after the rows
items.forEach((it, i) => {
const r = { x: card.x + PAD, y: list.y + i * rowH - scroll, w: listW - PAD * 2 - (maxScroll ? 12 : 0), h: rowH };
if (r.y + r.h < list.y || r.y > list.y + list.h) return; // scrolled out of view
@@ -110,16 +124,30 @@
g.globalAlpha = it.disabled ? 0.38 : 1;
const fg = it.on ? P.onSecondaryContainer : P.onSurface;
g.fillStyle = fg;
if (it.on) S.draw(g, 'check', lead ? r.x + r.w - 30 : r.x + 32, r.y + r.h / 2, 28, false);
(it.icons || []).forEach((name, k) => S.draw(g, name, r.x + ICON_LEFT + ICON_STEP * k + ICON_STEP / 2, r.y + r.h / 2, ICON, false));
const lines = String(it.text).split('\n'), textW = r.w - textX - (lead ? 52 : 12);
if (it.on) S.draw(g, 'check', lead || pics ? r.x + r.w - 30 : r.x + 32, r.y + r.h / 2, 28, false);
if (pics) { // its picture (a grey box until it loads), the corner text on it
const tx = r.x + 12, ty = r.y + (r.h - THUMB.h) / 2, img = it.image && kit.image(it.image);
g.save(); roundRect(g, tx, ty, THUMB.w, THUMB.h, 10); g.clip();
if (img) kit.drawImageCover(img, tx, ty, THUMB.w, THUMB.h); else { g.fillStyle = P.surfaceContainerHighest; g.fillRect(tx, ty, THUMB.w, THUMB.h); }
g.restore();
if (it.corner) {
g.font = T.font('labelMedium', 15); const cw = g.measureText(it.corner).width + 10;
g.fillStyle = 'rgba(0,0,0,0.75)'; roundRect(g, tx + THUMB.w - cw - 5, ty + THUMB.h - 25, cw, 20, 4); g.fill();
g.fillStyle = '#FFFFFF'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(it.corner, tx + THUMB.w - cw / 2 - 5, ty + THUMB.h - 14);
g.fillStyle = fg;
}
}
(it.icons || []).forEach((icon, k) => S.drawIcon(g, icon, r.x + ICON_LEFT + ICON_STEP * k + ICON_STEP / 2, r.y + r.h / 2, ICON, false));
const lines = String(it.text).split('\n'), textW = r.w - textX - (lead || pics ? 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;
hoverRing(g, r.x, r.y, r.w, r.h, rad, hv);
if (hv > 0.01) rings.push([r, rad, hv]);
});
// The pointing rings last, so a neighbouring row (e.g. the ticked one's fill) can't cover one.
for (const [r, rad, hv] of rings) hoverRing(g, r.x, r.y, r.w, r.h, rad, hv);
g.restore();
if (maxScroll) { // the scroll bar: where the view is in the list
const th = Math.max(36, list.h * list.h / (list.h + maxScroll)), ty = list.y + (list.h - th) * scroll / maxScroll;
@@ -169,12 +197,14 @@
const scrollAt = (u, v) => maxScroll > 0 && !!list && inside(list, u * W, v * H);
const scrollBy = px => { scroll = Math.min(maxScroll, Math.max(0, scroll + px)); };
return { canvas, aspect: H / W, draw, hitTest, solidAt, moving: motion.moving, volumeFraction, bottomCentre: { x: W / 2, y: H },
cardRect: () => card, // the last drawn card { x, y, w, h } (px), or null
scrollAt, scrollBy, scrollTo: px => { scroll = Math.min(maxScroll, Math.max(0, px)); }, shownScroll: () => scroll, pxPerV: H };
}
function createDialog() {
const W = 720, PAD = 32, BTN_H = 56, LINE = 30, MAX_LINES = 4; // the title, up to four lines of text, the buttons
const TEXT_TOP = PAD + 50; // where the text starts in the card
const FIELD_H = 60; // a text field's height (with a field, the text gets at most two lines)
const H = TEXT_TOP + MAX_LINES * LINE + 20 + BTN_H + PAD; // room for the tallest card
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
@@ -191,19 +221,35 @@
regions = [];
g.clearRect(0, 0, W, H);
g.font = T.font('bodyMedium', 22);
const lines = d.text ? ns.vrKit.wrap(g, d.text, W - PAD * 2, MAX_LINES) : [];
const cardH = (lines.length ? TEXT_TOP + lines.length * LINE : TEXT_TOP - 10) + 20 + BTN_H + PAD;
const lines = d.text ? ns.vrKit.wrap(g, d.text, W - PAD * 2, d.field ? 2 : MAX_LINES) : [];
const fieldRoom = d.field ? FIELD_H + 20 : 0;
const cardH = (lines.length ? TEXT_TOP + lines.length * LINE : TEXT_TOP - 10) + fieldRoom + 20 + BTN_H + PAD;
const y0 = Math.round((H - cardH) / 2);
g.fillStyle = T.panelFill(); roundRect(g, 0, y0, W, cardH, T.shape.extraLarge); g.fill(); // the panels' glass (or solid)
g.fillStyle = P.onSurface; g.textAlign = 'left'; g.textBaseline = 'top';
g.fillText(fitFont(g, d.title || '', W - PAD * 2, 32, '500 '), PAD, y0 + PAD);
g.fillStyle = P.onSurfaceVariant; g.font = T.font('bodyMedium', 22);
lines.forEach((line, i) => g.fillText(line, PAD, y0 + TEXT_TOP + i * LINE));
if (d.field) { // the text field: outlined in the brand colour (it has the keys)
const fy = y0 + (lines.length ? TEXT_TOP + lines.length * LINE : TEXT_TOP - 10) + 12, fw = W - PAD * 2;
g.fillStyle = P.surfaceContainerHighest; roundRect(g, PAD, fy, fw, FIELD_H, T.shape.small); g.fill();
g.strokeStyle = P.brand; g.lineWidth = 2.5; roundRect(g, PAD + 1, fy + 1, fw - 2, FIELD_H - 2, T.shape.small); g.stroke();
const t = d.field.text || '';
g.font = T.font('bodyMedium', 24); g.textBaseline = 'middle';
let shown = t; // (the end of a long name)
while (shown.length > 1 && g.measureText(shown).width > fw - 48) shown = shown.slice(1);
g.fillStyle = t ? P.onSurface : P.onSurfaceVariant;
g.fillText(t ? shown : d.field.placeholder || '', PAD + 18, fy + FIELD_H / 2 + 1);
const cx = PAD + 18 + (t ? g.measureText(shown).width + 2 : 0);
g.fillStyle = P.brand; g.fillRect(cx, fy + 14, 2.5, FIELD_H - 28); // the caret
g.textBaseline = 'top';
}
g.font = T.font('labelLarge', 22);
const bw = t => Math.max(140, g.measureText(t).width + 56);
const cw = bw(d.confirm || ''), kw = bw(d.cancel || '');
const by = y0 + cardH - PAD - BTN_H;
button(g, regions, 'dialog:confirm', W - PAD - cw, by, cw, BTN_H, { label: d.confirm, kind: d.danger ? 'danger' : 'filled' }, ui);
button(g, regions, 'dialog:confirm', W - PAD - cw, by, cw, BTN_H, { label: d.confirm, kind: d.danger ? 'danger' : 'filled',
enabled: !d.field || !!(d.field.text || '').trim() }, ui);
if (d.cancel != null) button(g, regions, 'dialog:cancel', W - PAD - cw - 16 - kw, by, kw, BTN_H, { label: d.cancel, kind: 'tonal' }, ui);
}
function hitTest(u, v) { const x = u * W, y = v * H; const r = regions.find(q => inside(q, x, y)); return r ? { id: r.id } : null; }
+6 -6
View File
@@ -17,8 +17,8 @@
// ns.vrSafeZone.angles() the zone's edges in degrees from straight ahead:
// { left, right, top, bottom, mid (its middle height) }
// (right and up positive)
// ns.vrSafeZone.setAnchor(head, yaw) where you were and faced at the recenter (anchor:
// { pos: [x, y, z] (your eyes), yaw })
// ns.vrSafeZone.setAnchor(head, yaw, pitch) where you were and faced at the recenter (anchor:
// { pos: [x, y, z] (your eyes), yaw, pitch (radians up; 0 but for the lazy follow) })
// ns.vrSafeZone.point(yaw, pitch, r) the world point that many degrees right / up of the
// recenter direction, r metres from the anchor's eyes
// ns.vrSafeZone.set(patch), reset(), load()
@@ -39,7 +39,7 @@
const zone = { ...DEFAULTS, version: 0 };
const fov = { ...FALLBACK_FOV, measured: false };
const anchor = { pos: [0, 1.6, 0], yaw: 0 };
const anchor = { pos: [0, 1.6, 0], yaw: 0, pitch: 0 }; // (pitch: radians the layout is turned up, e.g. by the lazy follow)
let askedAt = -Infinity;
// Take what's remembered (or the defaults).
@@ -91,8 +91,8 @@
return { left: -hw, right: hw, top: mid + hh, bottom: mid - hh, mid };
}
function setAnchor(head, yaw) {
anchor.pos = [head.x, head.y, head.z]; anchor.yaw = yaw; zone.version++;
function setAnchor(head, yaw, pitch = 0) {
anchor.pos = [head.x, head.y, head.z]; anchor.yaw = yaw; anchor.pitch = pitch; zone.version++;
}
const preview = () => { askedAt = performance.now(); };
@@ -102,7 +102,7 @@
// the recenter direction (turned by the anchor's yaw, as panel placement is: its forward
// is [-sin, 0, -cos]). The layout places everything with it (vr-panel.js).
function point(yaw, pitch, r) {
const a = yaw * Math.PI / 180, b = pitch * Math.PI / 180, y0 = anchor.yaw;
const a = yaw * Math.PI / 180, b = pitch * Math.PI / 180 + anchor.pitch, y0 = anchor.yaw;
const lx = Math.sin(a) * Math.cos(b), ly = Math.sin(b), lz = -Math.cos(a) * Math.cos(b);
const c = Math.cos(y0), s = Math.sin(y0);
return [anchor.pos[0] + r * (lx * c + lz * s), anchor.pos[1] + r * ly, anchor.pos[2] + r * (-lx * s + lz * c)];
+54 -4
View File
@@ -17,12 +17,20 @@
// items: [cards for this page], page, pageCount, more,
// loading (with no items: the loading indicator above
// emptyText), now (seconds, its clock), emptyText,
// shape: 'square' (square cards, e.g. podcasts' covers:
// grid.js) | null (the standard 16:9 ones),
// body: null | { kind: 'collection', ... } (a playlist or
// other list opened from a card: collection.js, under a
// back button in the pills' place, scrolling: scroll px),
// emptyIcon (with no items: emptyText larger in the
// middle with this icon after it: a placeholder) }
// Control ids: 'tab:<id>', 'pill:<id>', 'item:<index on page>', 'page:prev', 'page:next',
// 'field:<id>' (the field), 'clear:<id>' (its ×), 'submit:<id>' (its button),
// 'suggest:<index>' (a suggestion).
// Also: hitTest(u, v) -> { id } | null, has(id), imagesReady(), perPage, canvas, aspect.
// 'suggest:<index>' (a suggestion); with a collection: 'back', 'action:<id>', 'row:<index>',
// 'text' (the page itself: a drag can start there).
// Scrolling (a collection): scrollAt(u, v), maxScroll(), shownScroll(), pxPerV.
// Also: hitTest(u, v) -> { id } | null, has(id), imagesReady(), perPage, perPageFor(shape),
// canvas, aspect.
(function (ns) {
'use strict';
@@ -52,6 +60,7 @@
function draw(m, ui) {
ui = motion.step(ui);
regions = [];
scrollable = false; maxScrollPx = 0; scrollPx = 0;
g.clearRect(0, 0, W, H);
g.fillStyle = T.panelFill(); roundRect(g, 0, 0, W, H, T.shape.extraLarge); g.fill();
@@ -61,6 +70,9 @@
g.fillText(m.title || '', PAD, HEAD_Y + HEAD_H / 2);
const titleW = g.measureText(m.title || '').width + 28;
grid.tabs(m.tabs, m.tab, HEAD_Y, HEAD_H, ui, regions, W - PAD, PAD + titleW);
// A collection opened from a card (collection.js) takes the pills row (its back button)
// and the grid's place, scrolling.
if (m.body && m.body.kind === 'collection') { drawCollection(m.body, m, ui); return; }
// A text field, or filter pills (left-aligned; ones that don't fit are left out), or a note.
if (m.field) drawField(m.field, ui);
@@ -68,7 +80,7 @@
else if (m.note) { g.fillStyle = P.onSurfaceVariant; g.font = T.font('bodyMedium', 22); g.textAlign = 'left'; g.textBaseline = 'middle'; g.fillText(m.note, PAD, PILL_Y + PILL_H / 2); }
grid.divider(DIVIDER_Y);
grid.grid(m.items || [], ui, regions, m.emptyText || '', m.loading ? m.now || 0 : null, m.emptyIcon || null);
grid.grid(m.items || [], ui, regions, m.emptyText || '', m.loading ? m.now || 0 : null, m.emptyIcon || null, m.shape || null);
grid.paging(m, ui, regions);
if (m.field && (m.field.suggestions || []).length) drawSuggestions(m.field, ui); // over the cards
}
@@ -82,11 +94,49 @@
// field without its button.
const drawSuggestions = (f, ui) => grid.suggestions(f, ui, regions, PAD, PILL_Y + PILL_H + 6, W - PAD * 2 - 150 - 12);
// ------------------------------------------------------------ collection page
// The back button ('back') on the pills row, then the page scrolling in the grid's area,
// with a scroll bar on the right when it's taller. The last draw's scroll state is kept
// for the host (scrollAt, maxScroll, shownScroll).
const collection = ns.vrStyleCollection.create(g, kit);
const VIEW = { x: PAD, y: GRID_TOP - 8, w: W - PAD * 2 - 24, h: H - (GRID_TOP - 8) - 20 };
let scrollable = false, maxScrollPx = 0, scrollPx = 0;
// A region pushed only for its part inside the scrolling area (nothing if none shows).
function clipRegion(id, x, y, w, h) {
const a = Math.max(VIEW.y, y), b = Math.min(VIEW.y + VIEW.h, y + h);
if (b > a) regions.push({ id, x, y: a, w, h: b - a });
}
function drawCollection(b, m, ui) {
g.font = `500 22px ${FONT}`;
const bw = g.measureText(b.back || '').width + 48 + 36;
ns.vrKit.button(g, regions, 'back', PAD, PILL_Y + 4, bw, 44, { icon: 'arrow_back', label: b.back || '' }, ui);
grid.divider(DIVIDER_Y);
const lay = collection.layout(b, VIEW.w);
scrollable = true;
maxScrollPx = Math.max(0, lay.height + 12 - VIEW.h);
scrollPx = Math.min(maxScrollPx, Math.max(0, collection.scrollFor(b, lay, VIEW.h, m.scroll)));
g.save(); g.beginPath(); g.rect(VIEW.x - 8, VIEW.y, VIEW.w + 16, VIEW.h); g.clip();
collection.draw(b, lay, m, ui, VIEW.x, VIEW.y - scrollPx, VIEW, clipRegion);
g.restore();
if (maxScrollPx > 0) {
const barH = Math.max(40, VIEW.h * VIEW.h / (VIEW.h + maxScrollPx));
const barY = VIEW.y + (VIEW.h - barH) * (scrollPx / maxScrollPx);
g.fillStyle = PA('onSurface', 0.08); roundRect(g, W - 30, VIEW.y, 8, VIEW.h, 4); g.fill();
g.fillStyle = PA('onSurface', 0.45); roundRect(g, W - 30, barY, 8, barH, 4); g.fill();
}
regions.push({ id: 'text', x: VIEW.x - 8, y: VIEW.y, w: VIEW.w + 16, h: VIEW.h }); // (last: drags start here)
}
// (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?
// Is (u, v) on the scrolling collection page of the last draw?
const scrollAt = (u, v) => scrollable && u * W >= VIEW.x - 8 && u * W <= W - 16 && v * H >= VIEW.y && v * H <= VIEW.y + VIEW.h;
return { canvas, aspect: H / W, draw, hitTest, has, imagesReady: kit.imagesReady, perPage: grid.PER_PAGE, animating: () => motion.moving() || grid.moving() };
return { canvas, aspect: H / W, draw, hitTest, has, scrollAt, imagesReady: kit.imagesReady, perPage: grid.PER_PAGE,
maxScroll: () => maxScrollPx, shownScroll: () => scrollPx, pxPerV: H, // (a collection page's scrolling)
perPageFor: shape => (shape === 'square' ? grid.PER_PAGE_SQUARE : grid.PER_PAGE), // cards per page in a layout
animating: () => motion.moving() || grid.moving() };
}
ns.vrStyles = ns.vrStyles || {};
+149
View File
@@ -0,0 +1,149 @@
// A collection page (canvas 2D), shared by the styles that open one in place of their list
// (Catalog, Profile): a picture with the title, grey lines and a description beside it, a
// row of buttons, then the collection's items as rows (number, thumbnail with its corner
// text, title, a grey line), scrolling together. A playlist, an album, a season. Knows
// nothing about any site. The style draws the bar with the back button above it.
//
// const c = ns.vrStyleCollection.create(g, kit);
// c.layout(b, width) -> { head, listTop, rowH, height } laid out for a page `width` px wide
// (row i's top: listTop + i * rowH)
// c.scrollFor(b, lay, viewH, scroll) -> px the scroll to show (focusRow when scroll is null)
// c.draw(b, lay, m, ui, x, top, view, clipRegion) the page, content top at
// `top`, left edge x; only what's inside `view`
// ({ y, h }); m.now (seconds) turns the spinner
// body b: { kind: 'collection', back (the back button's label), image, title,
// lines: [grey lines] (e.g. its author, "24 videos · 1.2K views"), text (a description),
// buttons: [{ id, label, icon, kind: 'filled' | 'tonal', enabled }],
// rows: [{ thumb, corner (e.g. a duration), title, line (grey), current (playing now) }],
// loading (more rows coming: a spinner after them, or alone), emptyText, focusRow (a row
// to bring into view: a style given no scroll puts it a third of the way down) }
// Control ids: 'action:<id>' (its buttons), 'row:<index>' (a row).
(function (ns) {
'use strict';
const T = ns.vrTokens, P = T.m3, PA = T.m3Alpha; // design tokens (tokens.js)
const { roundRect, wrap, FONT } = ns.vrKit;
const CL = { imgMax: 360, gap: 24, title: 28, titleLine: 36, line: 28, text: 19, textLine: 26,
btnH: 52, btnGap: 12, rowH: 100, num: 40, thumbW: 152, thumbH: 86, rowTitle: 21, rowLine: 27, more: 64 };
function create(g, kit) {
// The heading's size at `width`: the picture (16:9) and the text column beside it; the
// description gets the lines left beside the picture (at least 2).
function layout(b, width) {
const imgW = Math.min(CL.imgMax, Math.round(width * 0.42)), imgH = Math.round(imgW * 9 / 16);
const textW = width - imgW - CL.gap;
g.font = T.font('titleLargeEmphasized', CL.title);
const title = wrap(g, b.title || '', textW, 2);
g.font = `${CL.text + 1}px ${FONT}`;
const lines = (b.lines || []).filter(Boolean).map(l => wrap(g, l, textW, 1)[0] || '');
const used = title.length * CL.titleLine + 8 + lines.length * CL.line + 8;
g.font = `${CL.text}px ${FONT}`;
const fit = Math.max(2, Math.floor((imgH - used) / CL.textLine));
const text = b.text ? wrap(g, b.text.replace(/\s+/g, ' '), textW, fit) : [];
const headH = Math.max(imgH, used + text.length * CL.textLine);
const rows = (b.rows || []).length;
const listTop = headH + CL.gap + ((b.buttons || []).length ? CL.btnH + CL.gap : 0) + 2 + 12;
const height = listTop + rows * CL.rowH + (b.loading || !rows ? CL.more : 0);
return { head: { imgW, imgH, textW, title, lines, text, headH }, listTop, rowH: CL.rowH, height };
}
function draw(b, lay, m, ui, x, top, view, clipRegion) {
const { imgW, imgH, textW, title, lines, text, headH } = lay.head;
const width = imgW + CL.gap + textW;
const shows = (y, h) => y + h >= view.y && y <= view.y + view.h;
g.textAlign = 'left'; g.textBaseline = 'top';
// The heading: the picture, then the title, grey lines and description beside it.
if (shows(top, headH)) {
g.save(); roundRect(g, x, top, imgW, imgH, 16); g.clip();
const img = kit.image(b.image);
if (img) kit.drawImageCover(img, x, top, imgW, imgH);
else { g.fillStyle = P.surfaceContainerHighest; g.fillRect(x, top, imgW, imgH); }
g.restore();
const tx = x + imgW + CL.gap;
let ty = top;
g.fillStyle = P.onSurface; g.font = T.font('titleLargeEmphasized', CL.title);
for (const l of title) { g.fillText(l, tx, ty); ty += CL.titleLine; }
ty += 8;
g.fillStyle = P.onSurfaceVariant; g.font = `${CL.text + 1}px ${FONT}`;
for (const l of lines) { g.fillText(l, tx, ty); ty += CL.line; }
ty += 8;
g.font = `${CL.text}px ${FONT}`;
for (const l of text) { g.fillText(l, tx, ty); ty += CL.textLine; }
}
// The buttons, left to right, each as wide as its icon and label.
let y = top + headH + CL.gap;
if ((b.buttons || []).length) {
let bx = x;
g.font = `500 22px ${FONT}`;
for (const bt of b.buttons) {
const w = Math.round(g.measureText(bt.label || '').width) + (bt.icon ? 36 : 0) + 56;
if (shows(y, CL.btnH)) {
const own = []; // (kept to the part inside the view)
ns.vrKit.button(g, own, `action:${bt.id}`, bx, y, w, CL.btnH,
{ icon: bt.icon, label: bt.label, kind: bt.kind || 'tonal', enabled: bt.enabled !== false }, ui);
for (const r of own) clipRegion(r.id, r.x, r.y, r.w, r.h);
g.font = `500 22px ${FONT}`;
}
bx += w + CL.btnGap;
}
y += CL.btnH + CL.gap;
}
g.fillStyle = PA('onSurface', 0.12); g.fillRect(x, y, width, 2);
y += 2 + 12;
// The rows: number, thumbnail (its corner text on it), title (2 lines) and grey line.
const rows = b.rows || [];
rows.forEach((r, i) => {
const ry = y + i * CL.rowH;
if (!shows(ry, CL.rowH)) return;
const id = `row:${i}`;
clipRegion(id, x - 2, ry, width + 4, CL.rowH - 8);
if (r.current) { g.fillStyle = PA('onSurface', 0.08); roundRect(g, x - 2, ry, width + 4, CL.rowH - 8, 14); g.fill(); }
ns.vrKit.hoverArea(g, id, ui, x - 2, ry, width + 4, CL.rowH - 8, 14);
g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillStyle = P.onSurfaceVariant; g.font = `18px ${FONT}`;
if (r.current) { g.fillStyle = P.onSurface; ns.vrSymbols.draw(g, 'play_arrow', x + CL.num / 2 - 4, ry + (CL.rowH - 8) / 2, 24, true); }
else g.fillText(String(i + 1), x + CL.num / 2 - 4, ry + (CL.rowH - 8) / 2);
const tx = x + CL.num, ty = ry + (CL.rowH - 8 - CL.thumbH) / 2;
g.save(); roundRect(g, tx, ty, CL.thumbW, CL.thumbH, 10); g.clip();
const img = kit.image(r.thumb);
if (img) kit.drawImageCover(img, tx, ty, CL.thumbW, CL.thumbH);
else { g.fillStyle = P.surfaceContainerHighest; g.fillRect(tx, ty, CL.thumbW, CL.thumbH); }
g.restore();
if (r.corner) {
g.font = `500 15px ${FONT}`;
const cw = g.measureText(r.corner).width + 10;
g.fillStyle = 'rgba(0,0,0,0.75)'; roundRect(g, tx + CL.thumbW - cw - 5, ty + CL.thumbH - 25, cw, 20, 4); g.fill();
g.fillStyle = '#FFFFFF'; g.fillText(r.corner, tx + CL.thumbW - cw / 2 - 5, ty + CL.thumbH - 14);
}
const lx = tx + CL.thumbW + 18, lw = x + width - lx;
g.textAlign = 'left'; g.textBaseline = 'top';
g.fillStyle = P.onSurface; g.font = `500 ${CL.rowTitle}px ${FONT}`;
const tl = wrap(g, r.title || '', lw, 2);
let ly = ry + 8;
for (const l of tl) { g.fillText(l, lx, ly); ly += CL.rowLine; }
if (r.line) { g.fillStyle = P.onSurfaceVariant; g.font = `18px ${FONT}`; g.fillText(wrap(g, r.line, lw, 1)[0] || '', lx, ly + 2); }
});
// After the rows: the spinner while more load, or the empty text.
const endY = y + rows.length * CL.rowH;
if (b.loading) { if (shows(endY, CL.more)) ns.vrGlyphs.spinner(g, x + width / 2, endY + CL.more / 2, 18, m.now || 0, false); }
else if (!rows.length && b.emptyText) {
g.fillStyle = P.onSurfaceVariant; g.font = `22px ${FONT}`; g.textAlign = 'left'; g.textBaseline = 'middle';
g.fillText(b.emptyText, x, endY + CL.more / 2);
}
}
// Where to scroll to (px, before the style clamps it): `scroll`, or when that's null (not
// scrolled yet) the focus row a third of the way down a view `viewH` tall.
const scrollFor = (b, lay, viewH, scroll) => (scroll == null && b.focusRow != null
? lay.listTop + b.focusRow * lay.rowH - viewH / 3 : scroll || 0);
return { layout, draw, scrollFor };
}
ns.vrStyleCollection = { create };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+14 -5
View File
@@ -22,7 +22,8 @@
// MAX_THUMB_H tall) and rows as the space above the paging row allows.
// fit: move the paging row up to just under the last row of cards (no empty band between);
// the panel then trims its canvas to the returned `height`.
// Returns { PER_PAGE, height, tabs(...), pills(...), divider(y), grid(...), paging(...),
// Returns { PER_PAGE, PER_PAGE_SQUARE (cards per page in the square layout), height,
// tabs(...), pills(...), divider(y), grid(...), paging(...),
// point(x, y), moving() } (the sliding rows: see `strips`);
// each drawing call pushes its clickable rectangles onto the `regions` array it is given.
function createGrid(g, kit, W, H, gridTop, lines = 2, fit = false) {
@@ -34,6 +35,12 @@
const BOTTOM_Y = fit ? gridTop + ROWS * (CARD_H + GAP) - GAP + 28 : H - 82; // paging row
const PER_PAGE = COLS * ROWS;
const BOX = { w: CARD_W, h: CARD_H, thumbH: THUMB_H };
// The square layout (shape 'square', e.g. podcasts' covers): square pictures as tall as
// the standard ones, so the rows stay; as many columns as fit, spread across the width.
const SQ_COLS = Math.max(1, Math.floor((W - PAD * 2 + GAP) / (THUMB_H + GAP)));
const SQ_GAP = SQ_COLS > 1 ? (W - PAD * 2 - SQ_COLS * THUMB_H) / (SQ_COLS - 1) : 0;
const SQ_BOX = { w: THUMB_H, h: CARD_H, thumbH: THUMB_H };
const PER_PAGE_SQUARE = SQ_COLS * ROWS;
const ARROWS = { prev: { x: W - PAD - 124, y: BOTTOM_Y - 2, w: 56, h: 56 }, next: { x: W - PAD - 56, y: BOTTOM_Y - 2, w: 56, h: 56 } }; // round tonal buttons
// The tab row and the left-aligned chip row slide sideways when they're wider than their
@@ -168,7 +175,8 @@
// while loading (`loadingAt`: the clock in seconds, else null), under the loading
// indicator in the middle of the grid; with `emptyIcon` (a Material Symbols name), larger
// in the middle of the grid with the icon after it (a placeholder, e.g. "Coming soon").
function grid(cards, ui, regions, emptyText, loadingAt = null, emptyIcon = null) {
// shape: 'square' for the square layout (square cards), else the standard one.
function grid(cards, ui, regions, emptyText, loadingAt = null, emptyIcon = null, shape = null) {
if (!cards.length && emptyIcon) {
const cy = gridTop + (BOTTOM_Y - gridTop) / 2, ICON = 44;
g.font = T.font('titleMedium', 32); g.textAlign = 'left'; g.textBaseline = 'middle';
@@ -186,8 +194,9 @@
g.fillText(emptyText, PAD, gridTop + 40);
}
cards.forEach((card, i) => {
const col = i % COLS, row = Math.floor(i / COLS);
kit.drawCard(card, PAD + col * (BOX.w + GAP), gridTop + row * (BOX.h + GAP), BOX, ui, regions, `item:${i}`);
const sq = shape === 'square', cols = sq ? SQ_COLS : COLS, box = sq ? SQ_BOX : BOX, step = sq ? THUMB_H + SQ_GAP : BOX.w + GAP;
const col = i % cols, row = Math.floor(i / cols);
kit.drawCard(card, PAD + col * step, gridTop + row * (box.h + GAP), box, ui, regions, `item:${i}`);
});
}
@@ -210,7 +219,7 @@
arrow('next', 1, m.page < pages - 1, ui, regions);
}
return { PER_PAGE, height: BOTTOM_Y + 82, tabs, pills, field, suggestions, magnifier, divider, grid, paging, point, moving };
return { PER_PAGE, PER_PAGE_SQUARE, height: BOTTOM_Y + 82, tabs, pills, field, suggestions, magnifier, divider, grid, paging, point, moving };
}
ns.vrStyleGrid = { createGrid, PAD };
+54 -13
View File
@@ -12,7 +12,15 @@
// current (outlined in blue: the item playing now),
// progress (0-1: how much of it you've watched, a bar along the thumbnail's bottom in
// the brand colour (the site's theme); 0 or missing: no bar),
// fit ('contain': the whole picture, letterboxed, e.g. a product; default: cropped to fill),
// backdrop (with fit 'round': a picture behind the round one, dimmed, e.g. a banner),
// 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),
// icon (a Material Symbols name drawn in the middle of the thumbnail when there's no
// picture, e.g. 'shuffle'),
// quick: { icon, on } (a round button on the thumbnail's bottom-left while the card is
// pointed at, e.g. Add to queue; region 'quick:<n>' for a card 'item:<n>'; on: done,
// a check),
// 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) {
@@ -153,9 +161,16 @@
// clickable region `id` pushed onto `regions`; ui: { hover, pressed }.
function drawCard(card, x, y, box, ui, regions, id) {
const W_ = box.w, TH = box.thumbH, T = ns.vrTokens, P = T.m3;
// Its quick button (card.quick, e.g. Add to queue): 'quick:<n>' for a card 'item:<n>', in the
// thumbnail's bottom-left corner (the flags take the top), shown while the card or it is
// pointed at; pushed first so it wins over the card under it.
const quickId = card.quick && !card.inert ? `quick:${String(id).slice(String(id).indexOf(':') + 1)}` : null, QS = 48;
const quickR = quickId ? { x: x + 8, y: y + TH - QS - (card.progress > 0 ? 14 : 8), w: QS, h: QS } : null;
if (quickR) regions.push({ id: quickId, ...quickR });
if (!card.inert) regions.push({ id, x, y, w: W_, h: box.h });
// How far it's pointed at / pressed (motion.js: eased; 0 for a card just to look at).
const { h: hv, p: pv } = card.inert ? { h: 0, p: 0 } : ns.vrMotion.of(ui, id);
const own = card.inert ? { h: 0, p: 0 } : ns.vrMotion.of(ui, id), qm = quickId ? ns.vrMotion.of(ui, quickId) : { h: 0, p: 0 };
const hv = Math.max(own.h, qm.h), pv = own.p; // (pointing at its quick button counts as pointing at it)
const tileA = Math.min(1, Math.max(hv, pv));
const tile = { x: x - 8, y: y - 8, w: W_ + 16, h: box.h + 16, r: Math.max(4, ns.vrMotion.lerp(T.shape.large, T.shape.medium, pv)) };
if (tileA > 0.01) {
@@ -168,8 +183,18 @@
// Thumbnail (a grey box with a play triangle until it loads)
g.save(); roundRect(g, x, y, W_, TH, T.shape.medium); g.clip();
const img = image(card.thumb);
if (img && card.fit === 'contain') { g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH); drawImageContain(img, x, y, W_, TH); }
if (card.fit === 'round') { // a round picture (a person) in the middle of the box
const s = Math.round(TH * 0.8);
g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH);
const back = card.backdrop && image(card.backdrop); // (a picture behind it, dimmed: e.g. a channel's banner)
if (back) { drawImageCover(back, x, y, W_, TH); g.fillStyle = 'rgba(0,0,0,0.35)'; g.fillRect(x, y, W_, TH); }
avatar(card.thumb, card.title, x + (W_ - s) / 2, y + (TH - s) / 2, s);
} else if (img && card.fit === 'contain') { g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH); drawImageContain(img, x, y, W_, TH); }
else if (img) drawImageCover(img, x, y, W_, TH);
else if (card.icon) { // no picture, an icon instead (e.g. shuffle)
g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH);
g.fillStyle = P.onSurfaceVariant; ns.vrSymbols.draw(g, card.icon, x + W_ / 2, y + TH / 2, Math.round(TH * 0.45), false);
}
else {
g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH);
g.fillStyle = TC.outlineVariant; const cx = x + W_ / 2, cy = y + TH / 2;
@@ -206,6 +231,18 @@
} else { g.textAlign = 'center'; g.fillText(f.text, fx + fw / 2, y + 22); }
fx += fw + 6;
}
// The quick button, while the card is pointed at: a round dark button with its icon
// (filled in the brand colour once done, card.quick.on); itself pointed at: lighter, ringed.
if (quickR && hv > 0.01) {
const a0 = g.globalAlpha, cx = quickR.x + QS / 2, cy = quickR.y + QS / 2, on = !!card.quick.on;
g.globalAlpha = a0 * Math.min(1, hv);
g.fillStyle = on ? P.brand : qm.h > 0.01 ? 'rgba(40,40,40,0.92)' : 'rgba(0,0,0,0.75)';
g.beginPath(); g.arc(cx, cy, QS / 2, 0, Math.PI * 2); g.fill();
g.fillStyle = on ? P.onBrand : '#FFFFFF';
ns.vrSymbols.draw(g, on ? 'check' : card.quick.icon, cx, cy, 28, on);
g.globalAlpha = a0;
hoverRing(g, quickR.x, quickR.y, QS, QS, QS / 2, qm.h);
}
// Title (2 lines), then the grey lines (each with an optional badge after its text)
g.textAlign = 'left'; g.textBaseline = 'top';
@@ -393,9 +430,10 @@
// const strip = createStrip();
// strip.draw(g, regions, box, contentW, key, paint): box { x, y, w, h } is the row's room
// on the canvas; contentW how wide the row is; key changes when its contents do (back
// to the start). paint(ctx, regions, x) draws the row starting at x on ctx (canvas
// coordinates); a row that fits is painted straight onto g, else onto the strip's own
// canvas (shifted, faded, then copied in) and its regions are cut to what shows.
// to the start, unless the new key only adds to the end of the old one). paint(ctx,
// regions, x) draws the row starting at x on ctx (canvas coordinates); a row that fits
// is painted straight onto g, else onto the strip's own canvas (shifted, faded, then
// copied in) and its regions are cut to what shows.
// strip.point(x, y): where the laser is on the panel (canvas px; called from hitTest).
// strip.moving() -> true while it's sliding (keep redrawing).
const STRIP = { edge: 150, speed: 900, fade: 48, margin: 8, stale: 150 }; // px, px/s at the very end, px, px, ms
@@ -414,26 +452,29 @@
}
function draw(g, regions, b, contentW, key, paint) {
box = b; max = Math.max(0, contentW - b.w);
if (key !== lastKey) { lastKey = key; offset = 0; }
// New contents: back to the start, unless they only added to the end (more pills arriving).
if (key !== lastKey) { if (!(lastKey && key.startsWith(lastKey))) offset = 0; lastKey = key; }
const now = performance.now(), dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
last = now;
offset = Math.min(max, Math.max(0, offset + speed() * dt));
if (max <= 0) { offset = 0; paint(g, regions, b.x); return; }
const M = STRIP.margin;
if (cv.width !== b.w || cv.height !== b.h + M * 2) { cv.width = b.w; cv.height = b.h + M * 2; }
// (The strip's canvas reaches M past the row on every side, so the pointing ring round an
// item at either end isn't cut.)
if (cv.width !== b.w + M * 2 || cv.height !== b.h + M * 2) { cv.width = b.w + M * 2; cv.height = b.h + M * 2; }
sg.setTransform(1, 0, 0, 1, 0, 0); sg.clearRect(0, 0, cv.width, cv.height);
sg.setTransform(1, 0, 0, 1, -b.x, -(b.y - M));
sg.setTransform(1, 0, 0, 1, -(b.x - M), -(b.y - M));
const local = [];
paint(sg, local, b.x - offset);
sg.setTransform(1, 0, 0, 1, 0, 0);
// Fade the ends that have more beyond them.
sg.globalCompositeOperation = 'destination-in';
const grad = sg.createLinearGradient(0, 0, b.w, 0), f = STRIP.fade / b.w;
grad.addColorStop(0, offset > 0.5 ? 'rgba(0,0,0,0)' : '#000'); grad.addColorStop(f, '#000');
grad.addColorStop(1 - f, '#000'); grad.addColorStop(1, offset < max - 0.5 ? 'rgba(0,0,0,0)' : '#000');
const grad = sg.createLinearGradient(0, 0, cv.width, 0), a = M / cv.width, f = (M + STRIP.fade) / cv.width;
grad.addColorStop(offset > 0.5 ? a : 0, offset > 0.5 ? 'rgba(0,0,0,0)' : '#000'); grad.addColorStop(f, '#000');
grad.addColorStop(1 - f, '#000'); grad.addColorStop(offset < max - 0.5 ? 1 - a : 1, offset < max - 0.5 ? 'rgba(0,0,0,0)' : '#000');
sg.fillStyle = grad; sg.fillRect(0, 0, cv.width, cv.height);
sg.globalCompositeOperation = 'source-over';
g.drawImage(cv, b.x, b.y - M);
g.drawImage(cv, b.x - M, b.y - M);
for (const r of local) {
const x0 = Math.max(r.x, b.x), x1 = Math.min(r.x + r.w, b.x + b.w);
if (x1 - x0 > 12) regions.push({ ...r, x: x0, w: x1 - x0 });
+46 -15
View File
@@ -1,13 +1,14 @@
// Options style (canvas 2D): tabs of settings, in Material 3 Expressive. A tab is either up
// to three rows (M3 list items: the setting's name with a grey hint under it on the left,
// its control on the right: a switch, a slider with an optional Default button, a stepper
// or a button) or a grid of choices (e.g. audio tracks). YouTube's Settings (Playback,
// Options style (canvas 2D): tabs of settings, in Material 3 Expressive. A tab is either
// rows (M3 list items: the setting's name with a grey hint under it on the left, its control
// on the right: a switch, a slider with an optional Default button, a stepper or a button;
// more than fit scroll) or a grid of choices (e.g. audio tracks). YouTube's Settings (Playback,
// Audio, 2D Cinema, Laser, Keyboard, Language); any site's options. Knows nothing about any
// site (ARCHITECTURE.md).
//
// const style = ns.vrStyles.options.create(onImageLoaded, W, H); // canvas size in px (no images: unused)
// style.draw(model, ui)
// model: { tabs: [{ id, label, icon (Material Symbols name, optional) }], tab,
// model: { tabs: [{ id, label, icon (Material Symbols name, optional) }], tab, scroll (px:
// rows that don't all fit scroll),
// corner (grey text top-right; optional: it shares the row with the tabs, so
// only for a panel with few tabs),
// content: one of
@@ -23,7 +24,8 @@
// fit, the last button is "More (2/3)", which moves on a page) }
// Control ids: 'tab:<id>', 'switch:<id>', 'slider:<id>' (its hit test also reports the
// fraction along it), 'default:<id>', 'prev:<id>' / 'next:<id>' (a stepper's arrows),
// 'button:<id>', 'choice:<index in items>', 'choice:more'.
// 'button:<id>', 'choice:<index in items>', 'choice:more', 'text' (scrolling rows: a drag can start there).
// Scrolling (rows): scrollAt(u, v), maxScroll(), shownScroll(), pxPerV.
// Also: hitTest(u, v) -> { id, fraction? } | null, has(id), sliderFraction(u), imagesReady().
(function (ns) {
'use strict';
@@ -39,7 +41,7 @@
const CHOICES = { cols: 4, rows: 4, top: 124, h: 54, gap: 10 };
const SWITCH = { w: 72, h: 44 };
const BTN_H = 52; // buttons' height (Default, steppers' arrows are round: 56)
const MIN_ROW_H = 64; // the least height a row gets
const MIN_ROW_H = 76; // the least height a row gets (with more rows than fit, they scroll)
function create(onImageLoaded, W = 1024, H = 390) {
const canvas = document.createElement('canvas');
@@ -108,19 +110,28 @@
// Rows (M3 list items): each faded when `dim`; the name (with its hint under it) on the
// left, the control on the right, a slider's Default button and end captions. The rows
// share the room under the note evenly (at most 86 px each, at least MIN_ROW_H: rows that
// wouldn't fit are left out).
function drawRows(c, ui) {
// share the room under the note evenly (at most 86 px each); when they'd get less than
// MIN_ROW_H, each keeps MIN_ROW_H and the rows scroll (m.scroll px; a bar on the right
// says where; the last draw's state for the host: scrollAt, maxScroll, shownScroll).
let view = null, maxScrollPx = 0, scrollPx = 0;
function drawRows(c, ui, scrollWant) {
let top = CONTENT_Y;
if (c.note) {
g.fillStyle = P.onSurfaceVariant; g.textAlign = 'left'; g.textBaseline = 'middle';
g.fillText(fitFont(g, c.note, W - PAD * 2, 19), PAD, top + 14);
top += 34;
}
const rows = (c.rows || []).slice(0, Math.max(1, Math.floor((H - top - 12) / MIN_ROW_H))); // as many as fit
const rowH = Math.min(86, (H - top - 12) / Math.max(1, rows.length));
const rows = c.rows || [], room = H - top - 12;
const rowH = Math.max(MIN_ROW_H, Math.min(86, room / Math.max(1, rows.length)));
view = { y: top, h: room };
maxScrollPx = Math.max(0, rows.length * rowH - room);
scrollPx = Math.min(maxScrollPx, Math.max(0, scrollWant || 0));
const all = regions; // (the rows' controls are cut to what shows)
regions = [];
g.save(); g.beginPath(); g.rect(0, top, W, room); g.clip();
rows.forEach((row, i) => {
const y = top + rowH * i + rowH / 2, ctl = row.control || {}, enabled = ctl.enabled !== false;
const y = top + rowH * i + rowH / 2 - scrollPx, ctl = row.control || {}, enabled = ctl.enabled !== false;
if (y + rowH / 2 < top || y - rowH / 2 > top + room) return; // scrolled out of view
g.globalAlpha = row.dim ? 0.38 : 1; rowFaded = !!row.dim;
// The name, and the hint under it (switch rows have the whole width up to the switch).
const nameW = ctl.type === 'switch' || !ctl.type ? W - PAD * 2 - SWITCH.w - 24 : LABEL_W;
@@ -149,7 +160,19 @@
}
}
});
g.restore();
g.globalAlpha = 1; rowFaded = false;
for (const r of regions) { // keep each control's part inside the rows' area
const a = Math.max(top, r.y), b = Math.min(top + room, r.y + r.h);
if (b - a > 8) all.push({ ...r, y: a, h: b - a });
}
regions = all;
if (maxScrollPx > 0) { // the scroll bar
const barH = Math.max(36, room * room / (room + maxScrollPx)), barY = top + (room - barH) * (scrollPx / maxScrollPx);
g.fillStyle = PA('onSurface', 0.08); roundRect(g, W - 22, top, 8, room, 4); g.fill();
g.fillStyle = PA('onSurface', 0.45); roundRect(g, W - 22, barY, 8, barH, 4); g.fill();
regions.push({ id: 'text', x: 0, y: top, w: W, h: room }); // (last: a drag can start anywhere on the rows)
}
}
// Choices: a 4 x 4 grid of tonal buttons, the chosen one primary with a check
@@ -198,8 +221,9 @@
headerTabs(g, m.tabs, m.tab, ui, regions, PAD, { x: PAD - 16, y: TAB_Y, h: TAB_H, maxX: tabsEnd + 16, fill: true });
g.fillStyle = P.outlineVariant; g.fillRect(PAD - 16, LINE_Y, W - PAD * 2 + 32, 1);
const c = m.content || { kind: 'rows', rows: [] };
view = null; maxScrollPx = 0; scrollPx = 0;
if (c.kind === 'choices') drawChoices(c, ui);
else drawRows(c, ui);
else drawRows(c, ui, m.scroll);
}
// Fraction (0..1) along a slider for any u (also used while dragging off it).
@@ -211,7 +235,14 @@
}
const has = id => regions.some(r => r.id === id); // an enabled control from the last draw?
return { canvas, aspect: H / W, draw, hitTest, has, sliderFraction, imagesReady: () => 0, perPage: 0, animating: motion.moving };
// Is (u, v) on rows that scroll (not on a slider: dragging that moves the slider)?
const scrollAt = (u, v) => {
const x = u * W, y = v * H;
return maxScrollPx > 0 && !!view && y >= view.y && y <= view.y + view.h
&& !regions.some(r => r.id.startsWith('slider:') && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h);
};
return { canvas, aspect: H / W, draw, hitTest, has, sliderFraction, imagesReady: () => 0, perPage: 0, animating: motion.moving,
scrollAt, maxScroll: () => maxScrollPx, shownScroll: () => scrollPx, pxPerV: H };
}
ns.vrStyles = ns.vrStyles || {};
+105 -6
View File
@@ -13,6 +13,8 @@
// instead of the title while header is null),
// action: { id, label, look: 'primary' | 'secondary' | 'danger' | 'busy' } | null
// (one button on the right; 'busy' isn't clickable),
// iconAction: { id, icon, look: 'primary' | 'secondary' | 'busy' } | null (a round
// icon button just left of the action button, e.g. one that opens a menu),
// tabs: [{ id, label }], tab, pills: [{ id, label }], pill,
// search: bool (a search button at the tab row's end, 'search'),
// field: null | a text field (grid.js: { id, text, placeholder, focused, button,
@@ -26,10 +28,17 @@
// { kind: 'page', text (paragraphs split by "\n"), facts: [{ label, value }]
// (two columns under the text), loading, emptyText }
// { kind: 'feed', posts: [post], loading, emptyText }
// { kind: 'tiers', image, kicker, title, intro: [lines], tiers: [{ title, price,
// button: { id, label, enabled } | null, note, items: [{ title, text, images:
// [urls] }] }], footer, loading, emptyText } (plans or levels to join, e.g. a
// channel's memberships: tiersPage)
// { kind: 'collection', back, ... } (a playlist or other list opened from a
// card: collection.js, under a back button)
// { kind: 'post', post, back (the back button's label), thread (thread.js's
// content: its pills and count sit on the bar beside the back button) }
// post: { at (the id posts' controls carry; default: the index in posts),
// author, avatar, meta, text, images: [urls],
// author, avatar, meta, flags: [{ text, bg, fg }] (after the author, e.g.
// "Members only"; colour role specs as on cards), text, images: [urls],
// card (a generic card, e.g. an attached video; clickable) | null,
// poll: { choices: [{ text, on (your choice), percent (0-100: shown as a bar,
// null: no results shown) }], note, enabled (choices clickable) } | null,
@@ -37,11 +46,14 @@
// footer (grey text, after the buttons) }
// Control ids: 'action:<id>', 'tab:<id>', 'pill:<id>', 'item:<index on page>', 'featured',
// 'page:prev', 'page:next', 'post:<at>' (its card), 'vote:<at>.<choice>', '<name>:<at>'
// (its buttons), 'back' (an opened post, or searching), 'search', 'field:<id>' /
// 'clear:<id>' / 'submit:<id>' (searching), the thread's own (thread.js), 'text' (anywhere
// (its buttons), 'back' (an opened post or collection, or searching), 'search', 'field:<id>' /
// 'clear:<id>' / 'submit:<id>' (searching), 'row:<index>' (a collection's row; its buttons
// are 'action:<id>'), the thread's own (thread.js), 'text' (anywhere
// else on a scrolling body: a trigger drag can start there).
// Scrolling (a body): scrollAt(u, v), maxScroll(), shownScroll(), pxPerV.
// Also: hitTest(u, v) -> { id } | null, has(id), imagesReady(), perPage, canvas, aspect.
// Also: hitTest(u, v) -> { id } | null, has(id), rectOf(id) -> { x, y, w, h } (px) | null
// (a control's place in the last draw, for a menu opening from it), imagesReady(),
// perPage, canvas, aspect.
(function (ns) {
'use strict';
@@ -60,6 +72,7 @@
// the same everywhere on this page).
function create(onImageLoaded, W = 1280, H = 960, overrides = {}) {
const ACTION = { x: W - PAD - 240, y: 132, w: 240, h: 60 };
const ICON_ACTION = { x: ACTION.x - 60 - 16, y: ACTION.y, w: 60, h: 60 };
// A narrow panel puts the pills on a row of their own under the tabs, so the tabs keep
// their names; everything below moves down with it.
const PILLS_Y = W < NARROW ? TAB_Y + TAB_H + 10 : TAB_Y;
@@ -72,6 +85,7 @@
const grid = ns.vrStyleGrid.createGrid(g, kit, W, H, GRID_TOP, 1);
const slots = { threadFooter: f => f.x, ...(overrides.slots || {}) };
const thread = ns.vrStyleThread.create(g, kit, { slots, fill: (id, ui, base) => ns.vrKit.buttonFill(id, ui, base) });
const collection = ns.vrStyleCollection.create(g, kit);
let regions = []; // clickable rectangles from the last draw
// Banner across the top (or a dark strip), fading into the panel at its bottom edge.
@@ -98,7 +112,7 @@
// Name (+ badge) and the stats line beside the picture.
function drawTitle(m) {
const h = m.header;
const maxW = ACTION.x - NAME_X - 24;
const maxW = (m.iconAction ? ICON_ACTION.x : ACTION.x) - NAME_X - 24;
g.textAlign = 'left'; g.textBaseline = 'middle';
g.fillStyle = P.onSurface; g.font = T.font('titleLargeEmphasized', 36);
const name = wrap(g, h ? h.title : m.loadingTitle, maxW - (h && h.badge ? 34 : 0), 1)[0] || '';
@@ -117,6 +131,9 @@
if (!a) return;
ns.vrKit.button(g, regions, `action:${a.id}`, ACTION.x, ACTION.y, ACTION.w, ACTION.h,
{ label: a.label, kind: KIND[a.look] || 'filled', enabled: a.look !== 'busy' }, ui);
const i = m.iconAction; // the round icon button left of it
if (i) ns.vrKit.button(g, regions, `action:${i.id}`, ICON_ACTION.x, ICON_ACTION.y, ICON_ACTION.w, ICON_ACTION.h,
{ icon: i.icon, kind: KIND[i.look] || 'tonal', enabled: i.look !== 'busy' }, ui);
}
// The featured card (id 'featured'): the thumbnail on the left (with its dot flags drawn
@@ -252,6 +269,63 @@
endScroll();
}
// 'tiers': a page of plans or levels to join (e.g. a channel's memberships): a round
// picture with a kicker (grey) and a title, the intro lines, then each tier: its title, its
// price, its button (id 'action:<id>', greyed when not enabled), its items (a title, a row
// of small pictures, a grey description), its note (grey); a grey footer at the end.
// One function lays it out and draws it (draw false: just measure), so the two agree.
const TIER = { avatar: 72, size: 22, small: 18, line: 30, smallLine: 26, img: 34, gap: 18, btnW: 280, btnH: 52 };
function tiersPage(b, ui, top, draw) {
let y = top;
const text = (lines, font, color, x = VIEW.x, lh = TIER.line) => {
for (const line of lines) { if (draw) { g.font = font; g.fillStyle = color; g.fillText(line, x, y + 4); } y += lh; }
};
const wrapped = (t, font, w = VIEW.w) => { g.font = font; return t ? wrapParas(t, w) : []; };
g.textAlign = 'left'; g.textBaseline = 'top';
// The heading: picture, kicker and title beside it.
if (b.image || b.title) {
if (draw) kit.avatar(b.image, b.title, VIEW.x, y, TIER.avatar);
const x = VIEW.x + TIER.avatar + 20, y0 = y;
y += 6;
if (b.kicker) text([b.kicker], `${TIER.small}px ${FONT}`, P.onSurfaceVariant, x, TIER.smallLine);
if (b.title) text([b.title], T.font('titleLargeEmphasized', 30), P.onSurface, x, 40);
y = Math.max(y, y0 + TIER.avatar) + TIER.gap;
}
for (const line of b.intro || []) text(wrapped(line, `${TIER.size}px ${FONT}`), `${TIER.size}px ${FONT}`, P.onSurface);
for (const t of b.tiers || []) {
y += TIER.gap;
if (draw) { g.fillStyle = PA('onSurface', 0.12); g.fillRect(VIEW.x, y, VIEW.w, 2); }
y += TIER.gap;
text([t.title || ''], T.font('titleMedium', 26), P.onSurface, VIEW.x, 36);
if (t.price) text([t.price], `500 ${TIER.size}px ${FONT}`, P.onSurface);
if (t.button) {
y += 8;
if (draw) ns.vrKit.button(g, regions, `action:${t.button.id}`, VIEW.x, y, TIER.btnW, TIER.btnH,
{ label: t.button.label, kind: 'filled', enabled: !!t.button.enabled }, ui);
y += TIER.btnH + 8;
}
if (t.note) text(wrapped(t.note, `${TIER.small}px ${FONT}`), `${TIER.small}px ${FONT}`, P.onSurfaceVariant, VIEW.x, TIER.smallLine);
for (const it of t.items || []) {
y += 12;
text(wrapped(it.title, `500 ${TIER.size}px ${FONT}`), `500 ${TIER.size}px ${FONT}`, P.onSurface);
if ((it.images || []).length) {
if (draw) it.images.forEach((url, k) => { const img = kit.image(url); if (img) g.drawImage(img, VIEW.x + k * (TIER.img + 8), y + 2, TIER.img, TIER.img); });
y += TIER.img + 8;
}
if (it.text) text(wrapped(it.text, `${TIER.small}px ${FONT}`), `${TIER.small}px ${FONT}`, P.onSurfaceVariant, VIEW.x, TIER.smallLine);
}
}
if (b.footer) { y += TIER.gap; text(wrapped(b.footer, `${TIER.small}px ${FONT}`), `${TIER.small}px ${FONT}`, P.onSurfaceVariant, VIEW.x, TIER.smallLine); }
return y - top;
}
function drawTiers(b, m, ui) {
if (!(b.tiers || []).length) { bodyMessage(b.emptyText, b.loading ? m.now || 0 : null); return; }
const h = tiersPage(b, ui, 0, false);
const top = beginScroll(m, h + 12);
tiersPage(b, ui, top, true);
endScroll();
}
// A post: round picture, author and meta, the text, its pictures (up to 3 side by side;
// "+N" for more), its card (a standard card, 'post:<at>'), its poll ('vote:<at>.<k>':
// a bar per choice, filled to its share once results show, yours outlined), then its
@@ -282,6 +356,16 @@
kit.avatar(p.avatar, p.author, x0, py, POST.avatar);
g.textAlign = 'left'; g.textBaseline = 'top';
g.fillStyle = P.onSurface; g.font = `500 20px ${FONT}`; g.fillText(p.author || '', x, py);
// Its flags after the author's name: 24 px tall pills, 6 px apart.
let fx = x + g.measureText(p.author || '').width + 12;
g.font = `600 16px ${FONT}`;
for (const f of p.flags || []) {
const fw = g.measureText(f.text).width + 16;
g.fillStyle = T.resolve(f.bg); roundRect(g, fx, py - 2, fw, 24, T.shape.small); g.fill();
g.fillStyle = T.resolve(f.fg); g.textAlign = 'center'; g.fillText(f.text, fx + fw / 2, py + 2);
fx += fw + 6;
}
g.textAlign = 'left';
g.fillStyle = P.onSurfaceVariant; g.font = `18px ${FONT}`; g.fillText(p.meta || '', x, py + 26);
py = y0 + POST.head;
g.fillStyle = P.onSurface; g.font = `${POST.size}px ${FONT}`;
@@ -395,6 +479,18 @@
endScroll();
}
// 'collection': the bar (back button 'back'), then the collection page (collection.js:
// a playlist's details, buttons and videos) scrolling under it.
function drawCollection(b, m, ui) {
g.font = `500 22px ${FONT}`;
const bw = g.measureText(b.back || '').width + 48 + 36;
ns.vrKit.button(g, regions, 'back', PAD, GRID_TOP - 4, bw, BAR_H, { icon: 'arrow_back', label: b.back || '' }, ui);
const lay = collection.layout(b, POST_VIEW.w);
const top = beginScroll({ scroll: collection.scrollFor(b, lay, POST_VIEW.h, m.scroll) }, lay.height + 12, POST_VIEW);
collection.draw(b, lay, m, ui, POST_VIEW.x, top, POST_VIEW, clipRegion);
endScroll();
}
// The search button at the right end of the tab row ('search'): a magnifier on a round
// button. Returns where it starts (x).
function drawSearchButton(ui) {
@@ -438,6 +534,8 @@
if (m.body) {
if (m.body.kind === 'feed') drawFeed(m.body, m, ui);
else if (m.body.kind === 'post') drawOpenPost(m.body, m, ui);
else if (m.body.kind === 'tiers') drawTiers(m.body, m, ui);
else if (m.body.kind === 'collection') drawCollection(m.body, m, ui);
else drawPage(m.body, m);
return;
}
@@ -449,11 +547,12 @@
// (It also tells the sliding rows where the laser is: grid.js strips.)
const hitTest = (u, v) => { grid.point(u * W, v * H); const r = regionAt(regions, u, v, W, H); return r ? { id: r.id } : null; };
const has = id => regions.some(r => r.id === id); // an enabled control from the last draw?
const rectOf = id => { const r = regions.find(x => x.id === id); return r ? { x: r.x, y: r.y, w: r.w, h: r.h } : null; };
// Is (u, v) on the scrolling body of the last draw?
const scrollAt = (u, v) => scrollable && u * W >= curView.x - 8 && u * W <= W - 16 && v * H >= curView.y && v * H <= curView.y + curView.h;
return {
canvas, aspect: H / W, draw, hitTest, has, scrollAt, imagesReady: kit.imagesReady, perPage: grid.PER_PAGE, animating: () => motion.moving() || grid.moving(),
canvas, aspect: H / W, draw, hitTest, has, rectOf, scrollAt, imagesReady: kit.imagesReady, perPage: grid.PER_PAGE, animating: () => motion.moving() || grid.moving(),
maxScroll: () => maxScrollPx, // how far the last drawn body can scroll (px)
shownScroll: () => scrollPx, // where it actually is (after clamping)
pxPerV: H, // canvas pixels per unit of v (for drag scrolling)
+57 -8
View File
@@ -1,15 +1,22 @@
// Queue style (canvas 2D): a short paged grid of cards with the title or tabs and the page
// arrows in its header. What's next to watch: YouTube's "Up Next" (Recommended / a
// playlist's videos); another site's episodes or "More like this". Knows nothing about any
// site: the provider fills the model (ARCHITECTURE.md).
// Queue style (canvas 2D): a short paged grid of cards (or a playlist as a page) with the
// title or tabs and the page arrows in its header. What's next to watch: YouTube's "Up Next"
// (Recommended / a playlist's videos); another site's episodes or "More like this". Knows
// nothing about any site: the provider fills the model (ARCHITECTURE.md).
//
// const style = ns.vrStyles.queue.create(onImageLoaded, W, H); // canvas size in px
// style.draw(model, ui) model: { title (when there are no tabs), tabs: null | [{ id, label, icon }],
// tab, items: [cards for this page] (kit.js), page, pageCount,
// emptyText (shown when there are no items) }
// emptyText (shown when there are no items),
// body: null | { kind: 'collection', ... } (a playlist as a page in
// the cards' place: collection.js; under a back button when
// it has `back`; scrolling: scroll px, null to show its
// focusRow), now (seconds, the spinner's clock) }
// ui: { hover, pressed } (control ids)
// Control ids: 'tab:<id>', 'page:prev', 'page:next', 'item:<index on page>'.
// Also: hitTest(u, v) -> { id } | null, has(id), imagesReady(), perPage, canvas, aspect.
// Control ids: 'tab:<id>', 'page:prev', 'page:next', 'item:<index on page>'; with a
// collection: 'back', 'action:<id>', 'row:<index>', 'text' (the page: a drag can start there).
// Scrolling (a collection): scrollAt(u, v), maxScroll(), shownScroll(), pxPerV.
// Also: hitTest(u, v) -> { id } | null, has(id), rectOf(id) -> { x, y, w, h } (px) | null,
// imagesReady(), perPage, canvas, aspect.
(function (ns) {
'use strict';
@@ -46,12 +53,48 @@
button(g, regions, `page:${key}`, r.x, r.y, r.w, r.h, { icon: dir < 0 ? 'chevron_left' : 'chevron_right', enabled }, ui);
}
// ------------------------------------------------------------ collection page
// A playlist as a page (collection.js) in the cards' place: a back button first when it
// has one, then the page scrolling, with a scroll bar on the right when it's taller. The
// last draw's scroll state is kept for the host (scrollAt, maxScroll, shownScroll).
const collection = ns.vrStyleCollection.create(g, kit);
let scrollable = false, maxScrollPx = 0, scrollPx = 0, view = null;
function clipRegion(id, x, y, w, h) {
const a = Math.max(view.y, y), b = Math.min(view.y + view.h, y + h);
if (b > a) regions.push({ id, x, y: a, w, h: b - a });
}
function drawCollection(b, m, ui) {
let y = TOP;
if (b.back) {
g.font = `500 22px ${FONT}`;
const bw = g.measureText(b.back).width + 48 + 36;
button(g, regions, 'back', PAD, y, bw, 44, { icon: 'arrow_back', label: b.back }, ui);
y += 44 + 16;
}
view = { x: PAD, y, w: W - PAD * 2 - 24, h: H - y - BOTTOM };
const lay = collection.layout(b, view.w);
scrollable = true;
maxScrollPx = Math.max(0, lay.height + 12 - view.h);
scrollPx = Math.min(maxScrollPx, Math.max(0, collection.scrollFor(b, lay, view.h, m.scroll)));
g.save(); g.beginPath(); g.rect(view.x - 8, view.y, view.w + 16, view.h); g.clip();
collection.draw(b, lay, m, ui, view.x, view.y - scrollPx, view, clipRegion);
g.restore();
if (maxScrollPx > 0) {
const barH = Math.max(40, view.h * view.h / (view.h + maxScrollPx));
const barY = view.y + (view.h - barH) * (scrollPx / maxScrollPx);
g.fillStyle = PA('onSurface', 0.08); roundRect(g, W - 30, view.y, 8, view.h, 4); g.fill();
g.fillStyle = PA('onSurface', 0.45); roundRect(g, W - 30, barY, 8, barH, 4); g.fill();
}
regions.push({ id: 'text', x: view.x - 8, y: view.y, w: view.w + 16, h: view.h }); // (last: drags start here)
}
// Paint the whole panel: background, header (the title, or the tabs; page counter and
// arrows when there is more than one page), then this page's cards (or emptyText).
const motion = ns.vrMotion.create(); // hover / press easing (motion.js)
function draw(m, ui) {
ui = motion.step(ui);
regions = [];
scrollable = false; maxScrollPx = 0; scrollPx = 0;
g.clearRect(0, 0, W, H);
g.fillStyle = T.panelFill(); roundRect(g, 0, 0, W, H, T.shape.extraLarge); g.fill();
@@ -69,6 +112,7 @@
drawArrow('next', 1, m.page < m.pageCount - 1, ui);
}
if (m.body && m.body.kind === 'collection') { drawCollection(m.body, m, ui); return; }
const items = m.items || [];
if (!items.length) {
g.fillStyle = P.onSurfaceVariant; g.font = `24px ${FONT}`; g.textAlign = 'left';
@@ -83,8 +127,13 @@
const hitTest = (u, v) => { 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?
// A control's place in the last draw (px; for a menu opening from it, e.g. a page's button), or null.
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 collection page of the last draw?
const scrollAt = (u, v) => scrollable && !!view && u * W >= view.x - 8 && u * W <= W - 16 && v * H >= view.y && v * H <= view.y + view.h;
return { canvas, aspect: H / W, draw, hitTest, has, imagesReady: kit.imagesReady, perPage: PER_PAGE, animating: motion.moving };
return { canvas, aspect: H / W, draw, hitTest, has, rectOf, scrollAt, imagesReady: kit.imagesReady, perPage: PER_PAGE, animating: motion.moving,
maxScroll: () => maxScrollPx, shownScroll: () => scrollPx, pxPerV: H }; // (a collection page's scrolling)
}
ns.vrStyles = ns.vrStyles || {};
+44 -1
View File
@@ -4,6 +4,7 @@
//
// ns.vrSymbols.draw(g, name, cx, cy, size, filled) centred on (cx, cy), `size` px square;
// returns false for an unknown name
// ns.vrSymbols.drawIcon(g, icon, cx, cy, size, filled) icon: a name, or { name, color, filled }
// ns.vrSymbols.has(name), ns.vrSymbols.NAMES
//
// Material Symbols: Copyright Google LLC, Apache License 2.0
@@ -44,18 +45,30 @@
'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'],
account_circle: [
'M234-276q51-39 114-61.5T480-360q69 0 132 22.5T726-276q35-41 54.5-93T800-480q0-133-93.5-226.5T480-800q-133 0-226.5 93.5T160-480q0 59 19.5 111t54.5 93Zm246-164q-59 0-99.5-40.5T340-580q0-59 40.5-99.5T480-720q59 0 99.5 40.5T620-580q0 59-40.5 99.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-80q53 0 100-15.5t86-44.5q-39-29-86-44.5T480-280q-53 0-100 15.5T294-220q39 29 86 44.5T480-160Zm0-360q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm0-60Zm0 360Z',
'M234-276q51-39 114-61.5T480-360q69 0 132 22.5T726-276q35-41 54.5-93T800-480q0-133-93.5-226.5T480-800q-133 0-226.5 93.5T160-480q0 59 19.5 111t54.5 93Zm246-164q-59 0-99.5-40.5T340-580q0-59 40.5-99.5T480-720q59 0 99.5 40.5T620-580q0 59-40.5 99.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-80Z'],
add_to_queue: [
'M440-480v80q0 17 11.5 28.5T480-360q17 0 28.5-11.5T520-400v-80h80q17 0 28.5-11.5T640-520q0-17-11.5-28.5T600-560h-80v-80q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640v80h-80q-17 0-28.5 11.5T320-520q0 17 11.5 28.5T360-480h80ZM160-200q-33 0-56.5-23.5T80-280v-480q0-33 23.5-56.5T160-840h640q33 0 56.5 23.5T880-760v480q0 33-23.5 56.5T800-200H640v40q0 17-11.5 28.5T600-120H360q-17 0-28.5-11.5T320-160v-40H160Zm0-80h640v-480H160v480Zm0 0v-480 480Z',
'M440-480v80q0 17 11.5 28.5T480-360q17 0 28.5-11.5T520-400v-80h80q17 0 28.5-11.5T640-520q0-17-11.5-28.5T600-560h-80v-80q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640v80h-80q-17 0-28.5 11.5T320-520q0 17 11.5 28.5T360-480h80ZM160-200q-33 0-56.5-23.5T80-280v-480q0-33 23.5-56.5T160-840h640q33 0 56.5 23.5T880-760v480q0 33-23.5 56.5T800-200H640v40q0 17-11.5 28.5T600-120H360q-17 0-28.5-11.5T320-160v-40H160Z'],
ads_click: [
'M480-640q-66 0-113 47t-47 113q0 45 22.5 83t62.5 59q16 8 22.5 23.5T427-283q-7 14-21 21t-28 0q-63-30-100.5-89T240-480q0-100 70-170t170-70q71 0 130.5 37.5T699-580q6 14-.5 28T678-532q-16 7-32 0t-24-23q-21-40-59-62.5T480-640Zm0-160q-134 0-227 93t-93 227q0 126 83 216t208 102q17 2 29.5 15t11.5 30q-1 17-12.5 28T451-79q-78-5-145.5-39t-118-88.5q-50.5-54.5-79-125T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q157 0 273.5 107T881-509q2 16-9.5 27.5T843-469q-17 1-30.5-11T797-509q-15-120-104.5-205.5T480-800Zm302 700L650-231l-30 91q-5 14-19 13.5T582-141l-91-303q-4-11 5-20t20-5l303 91q14 5 14.5 19T820-340l-91 30 132 132q17 17 17 39t-17 39q-17 17-39.5 17T782-100Z',
'M480-640q-66 0-113 47t-47 113q0 45 22.5 83t62.5 59q16 8 22.5 23.5T427-283q-7 14-21 21t-28 0q-63-30-100.5-89T240-480q0-100 70-170t170-70q71 0 130.5 37.5T699-580q6 14-.5 28T678-532q-16 7-32 0t-24-23q-21-40-59-62.5T480-640Zm0-160q-134 0-227 93t-93 227q0 126 83 216t208 102q17 2 29.5 15t11.5 30q-1 17-12.5 28T451-79q-78-5-145.5-39t-118-88.5q-50.5-54.5-79-125T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q157 0 273.5 107T881-509q2 16-9.5 27.5T843-469q-17 1-30.5-11T797-509q-15-120-104.5-205.5T480-800Zm302 700L650-231l-30 91q-5 14-19 13.5T582-141l-91-303q-4-11 5-20t20-5l303 91q14 5 14.5 19T820-340l-91 30 132 132q17 17 17 39t-17 39q-17 17-39.5 17T782-100Z'],
arrow_back: [
'm313-440 196 196q12 12 11.5 28T508-188q-12 11-28 11.5T452-188L188-452q-6-6-8.5-13t-2.5-15q0-8 2.5-15t8.5-13l264-264q11-11 27.5-11t28.5 11q12 12 12 28.5T508-715L313-520h447q17 0 28.5 11.5T800-480q0 17-11.5 28.5T760-440H313Z',
'm313-440 196 196q12 12 11.5 28T508-188q-12 11-28 11.5T452-188L188-452q-6-6-8.5-13t-2.5-15q0-8 2.5-15t8.5-13l264-264q11-11 27.5-11t28.5 11q12 12 12 28.5T508-715L313-520h447q17 0 28.5 11.5T800-480q0 17-11.5 28.5T760-440H313Z'],
autoplay: [
'M380-337v-286q0-12 10.5-18t20.5 1l223 143q9 6 9 17t-9 17L411-320q-10 7-20.5 1T380-337ZM120-228v68q0 17-11.5 28.5T80-120q-17 0-28.5-11.5T40-160v-160q0-17 11.5-28.5T80-360h160q17 0 28.5 11.5T280-320q0 17-11.5 28.5T240-280h-58q51 75 129.5 117.5T480-120q104 0 190-54t132-145q9-17 23.5-27t33.5-6q18 4 23.5 20.5T879-296q-54 116-161 186T480-40q-108 0-202.5-49.5T120-228ZM83-520q-17 0-27.5-12.5T48-562q10-47 26-86.5t43-79.5q10-15 26-17t29 11q14 14 14 30.5T175-670q-17 26-27 52t-18 57q-4 18-16.5 29.5T83-520Zm357-358q0 19-11.5 31T398-831q-30 7-55.5 18T291-785q-16 11-32.5 10T228-790q-12-12-10.5-27.5T233-844q39-26 77.5-42.5T396-912q18-3 31 7t13 27Zm294 88q-14 14-31 14.5T670-786q-26-17-52-27t-57-18q-18-4-29.5-16.5T520-878q0-17 12.5-27t29.5-7q48 9 87 25t79 43q14 10 16 26t-10 28Zm144 270q-19 0-31-11.5T831-562q-8-31-18.5-56.5T785-671q-11-16-10-32.5t15-30.5q12-12 27.5-10t26.5 16q27 40 43 79t25 87q3 17-7 29.5T878-520Z',
'M380-337v-286q0-12 10.5-18t20.5 1l223 143q9 6 9 17t-9 17L411-320q-10 7-20.5 1T380-337ZM120-228v68q0 17-11.5 28.5T80-120q-17 0-28.5-11.5T40-160v-160q0-17 11.5-28.5T80-360h160q17 0 28.5 11.5T280-320q0 17-11.5 28.5T240-280h-58q51 75 129.5 117.5T480-120q104 0 190-54t132-145q9-17 23.5-27t33.5-6q18 4 23.5 20.5T879-296q-54 116-161 186T480-40q-108 0-202.5-49.5T120-228ZM83-520q-17 0-27.5-12.5T48-562q10-47 26-86.5t43-79.5q10-15 26-17t29 11q14 14 14 30.5T175-670q-17 26-27 52t-18 57q-4 18-16.5 29.5T83-520Zm357-358q0 19-11.5 31T398-831q-30 7-55.5 18T291-785q-16 11-32.5 10T228-790q-12-12-10.5-27.5T233-844q39-26 77.5-42.5T396-912q18-3 31 7t13 27Zm294 88q-14 14-31 14.5T670-786q-26-17-52-27t-57-18q-18-4-29.5-16.5T520-878q0-17 12.5-27t29.5-7q48 9 87 25t79 43q14 10 16 26t-10 28Zm144 270q-19 0-31-11.5T831-562q-8-31-18.5-56.5T785-671q-11-16-10-32.5t15-30.5q12-12 27.5-10t26.5 16q27 40 43 79t25 87q3 17-7 29.5T878-520Z'],
back_hand: [
'M512-40q-82 0-154-37.5T240-182L63-442q-7-10-5.5-21.5T67-483q20-21 49.5-24t53.5 14l110 76v-383q0-17 11.5-28.5T320-840q17 0 28.5 11.5T360-800v460q0 24-21.5 35.5T297-307l-85-59 95 138q35 51 89 79.5T512-120q103 0 175.5-72.5T760-368v-392q0-17 11.5-28.5T800-800q17 0 28.5 11.5T840-760v392q0 137-95.5 232.5T512-40Zm-32-880q17 0 28.5 11.5T520-880v360q0 17-11.5 28.5T480-480q-17 0-28.5-11.5T440-520v-360q0-17 11.5-28.5T480-920Zm160 40q17 0 28.5 11.5T680-840v320q0 17-11.5 28.5T640-480q-17 0-28.5-11.5T600-520v-320q0-17 11.5-28.5T640-880ZM486-300Z',
'M512-40q-82 0-154-37.5T240-182L63-442q-7-10-5.5-21.5T67-483q20-21 49.5-24t53.5 14l110 76v-383q0-17 11.5-28.5T320-840q17 0 28.5 11.5T360-800v320h80v-400q0-17 11.5-28.5T480-920q17 0 28.5 11.5T520-880v400h80v-360q0-17 11.5-28.5T640-880q17 0 28.5 11.5T680-840v360h80v-280q0-17 11.5-28.5T800-800q17 0 28.5 11.5T840-760v392q0 137-95.5 232.5T512-40Z'],
@@ -107,6 +120,9 @@
chevron_right: [
'M504-480 348-636q-11-11-11-28t11-28q11-11 28-11t28 11l184 184q6 6 8.5 13t2.5 15q0 8-2.5 15t-8.5 13L404-268q-11 11-28 11t-28-11q-11-11-11-28t11-28l156-156Z',
'M504-480 348-636q-11-11-11-28t11-28q11-11 28-11t28 11l184 184q6 6 8.5 13t2.5 15q0 8-2.5 15t-8.5 13L404-268q-11 11-28 11t-28-11q-11-11-11-28t11-28l156-156Z'],
clear_all: [
'M160-280q-17 0-28.5-11.5T120-320q0-17 11.5-28.5T160-360h480q17 0 28.5 11.5T680-320q0 17-11.5 28.5T640-280H160Zm80-160q-17 0-28.5-11.5T200-480q0-17 11.5-28.5T240-520h480q17 0 28.5 11.5T760-480q0 17-11.5 28.5T720-440H240Zm80-160q-17 0-28.5-11.5T280-640q0-17 11.5-28.5T320-680h480q17 0 28.5 11.5T840-640q0 17-11.5 28.5T800-600H320Z',
'M160-280q-17 0-28.5-11.5T120-320q0-17 11.5-28.5T160-360h480q17 0 28.5 11.5T680-320q0 17-11.5 28.5T640-280H160Zm80-160q-17 0-28.5-11.5T200-480q0-17 11.5-28.5T240-520h480q17 0 28.5 11.5T760-480q0 17-11.5 28.5T720-440H240Zm80-160q-17 0-28.5-11.5T280-640q0-17 11.5-28.5T320-680h480q17 0 28.5 11.5T840-640q0 17-11.5 28.5T800-600H320Z'],
close: [
'M480-424 284-228q-11 11-28 11t-28-11q-11-11-11-28t11-28l196-196-196-196q-11-11-11-28t11-28q11-11 28-11t28 11l196 196 196-196q11-11 28-11t28 11q11 11 11 28t-11 28L536-480l196 196q11 11 11 28t-11 28q-11 11-28 11t-28-11L480-424Z',
'M480-424 284-228q-11 11-28 11t-28-11q-11-11-11-28t11-28l196-196-196-196q-11-11-11-28t11-28q11-11 28-11t28 11l196 196 196-196q11-11 28-11t28 11q11 11 11 28t-11 28L536-480l196 196q11 11 11 28t-11 28q-11 11-28 11t-28-11L480-424Z'],
@@ -116,6 +132,9 @@
comment: [
'M280-400h400q17 0 28.5-11.5T720-440q0-17-11.5-28.5T680-480H280q-17 0-28.5 11.5T240-440q0 17 11.5 28.5T280-400Zm0-120h400q17 0 28.5-11.5T720-560q0-17-11.5-28.5T680-600H280q-17 0-28.5 11.5T240-560q0 17 11.5 28.5T280-520Zm0-120h400q17 0 28.5-11.5T720-680q0-17-11.5-28.5T680-720H280q-17 0-28.5 11.5T240-680q0 17 11.5 28.5T280-640ZM160-240q-33 0-56.5-23.5T80-320v-480q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v623q0 27-24.5 37.5T812-148l-92-92H160Zm594-80 46 45v-525H160v480h594Zm-594 0v-480 480Z',
'M280-400h400q17 0 28.5-11.5T720-440q0-17-11.5-28.5T680-480H280q-17 0-28.5 11.5T240-440q0 17 11.5 28.5T280-400Zm0-120h400q17 0 28.5-11.5T720-560q0-17-11.5-28.5T680-600H280q-17 0-28.5 11.5T240-560q0 17 11.5 28.5T280-520Zm0-120h400q17 0 28.5-11.5T720-680q0-17-11.5-28.5T680-720H280q-17 0-28.5 11.5T240-680q0 17 11.5 28.5T280-640ZM160-240q-33 0-56.5-23.5T80-320v-480q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v623q0 27-24.5 37.5T812-148l-92-92H160Z'],
diamond: [
'M480-165q-17 0-33-7.5T419-194L113-560q-9-11-13.5-24T95-611q0-9 1.5-18.5T103-647l75-149q11-20 29.5-32t41.5-12h462q23 0 41.5 12t29.5 32l75 149q5 8 6.5 17.5T865-611q0 14-4.5 27T847-560L541-194q-12 14-28 21.5t-33 7.5Zm-95-475h190l-60-120h-70l-60 120Zm55 347v-267H218l222 267Zm80 0 222-267H520v267Zm144-347h106l-60-120H604l60 120Zm-474 0h106l60-120H250l-60 120Z',
'M368-630h224L486-840h-12L368-630Zm82 474v-414H105l345 414Zm60 0 345-414H510v414Zm148-474h207l-83-166q-11-20-29.5-32T711-840H554l104 210Zm-563 0h207l104-210H249q-23 0-41.5 12T178-796L95-630Z'],
drag_handle: [
'M200-360q-17 0-28.5-11.5T160-400q0-17 11.5-28.5T200-440h560q17 0 28.5 11.5T800-400q0 17-11.5 28.5T760-360H200Zm0-160q-17 0-28.5-11.5T160-560q0-17 11.5-28.5T200-600h560q17 0 28.5 11.5T800-560q0 17-11.5 28.5T760-520H200Z',
'M200-360q-17 0-28.5-11.5T160-400q0-17 11.5-28.5T200-440h560q17 0 28.5 11.5T800-400q0 17-11.5 28.5T760-360H200Zm0-160q-17 0-28.5-11.5T160-560q0-17 11.5-28.5T200-600h560q17 0 28.5 11.5T800-560q0 17-11.5 28.5T760-520H200Z'],
@@ -131,6 +150,9 @@
explore: [
'M480-440q-17 0-28.5-11.5T440-480q0-17 11.5-28.5T480-520q17 0 28.5 11.5T520-480q0 17-11.5 28.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320ZM297-277l250-117q6-3 11-8t8-11l117-250q5-10-2.5-17.5T663-683L413-566q-6 3-11 8t-8 11L277-297q-5 10 2.5 17.5T297-277Z',
'M480-440q-17 0-28.5-11.5T440-480q0-17 11.5-28.5T480-520q17 0 28.5 11.5T520-480q0 17-11.5 28.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0 0q-134 0-227-93t-93-227q0-134 93-227t227-93q134 0 227 93t93 227q0 134-93 227t-227 93Zm67-234q6-3 11-8t8-11l117-250q5-10-2.5-17.5T663-683L413-566q-6 3-11 8t-8 11L277-297q-5 10 2.5 17.5T297-277l250-117Z'],
fast_forward: [
'M100-315v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T428-480q0 10-4.5 18.5T410-447L162-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Zm400 0v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T828-480q0 10-4.5 18.5T810-447L562-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29ZM180-480Zm400 0Zm-400 90 136-90-136-90v180Zm400 0 136-90-136-90v180Z',
'M100-315v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T428-480q0 10-4.5 18.5T410-447L162-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Zm400 0v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T828-480q0 10-4.5 18.5T810-447L562-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Z'],
favorite: [
'M480-147q-14 0-28.5-5T426-168l-69-63q-106-97-191.5-192.5T80-634q0-94 63-157t157-63q53 0 100 22.5t80 61.5q33-39 80-61.5T660-854q94 0 157 63t63 157q0 115-85 211T602-230l-68 62q-11 11-25.5 16t-28.5 5Zm-38-543q-29-41-62-62.5T300-774q-60 0-100 40t-40 100q0 52 37 110.5T285.5-410q51.5 55 106 103t88.5 79q34-31 88.5-79t106-103Q726-465 763-523.5T800-634q0-60-40-100t-100-40q-47 0-80 21.5T518-690q-7 10-17 15t-21 5q-11 0-21-5t-17-15Zm38 189Z',
'M480-147q-14 0-28.5-5T426-168l-69-63q-106-97-191.5-192.5T80-634q0-94 63-157t157-63q53 0 100 22.5t80 61.5q33-39 80-61.5T660-854q94 0 157 63t63 157q0 115-85 211T602-230l-68 62q-11 11-25.5 16t-28.5 5Z'],
@@ -191,6 +213,9 @@
notifications_active: [
'M200-200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-83 50-147.5T420-792v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 84.5T720-560v280h40q17 0 28.5 11.5T800-240q0 17-11.5 28.5T760-200H200Zm280-300Zm0 420q-33 0-56.5-23.5T400-160h160q0 33-23.5 56.5T480-80ZM320-280h320v-280q0-66-47-113t-113-47q-66 0-113 47t-47 113v280ZM120-560q-17 0-28.5-13T82-603q8-75 42-139.5T211-855q13-11 29.5-10t26.5 15q10 14 8 30t-15 28q-39 37-64 86t-33 106q-2 17-14 28.5T120-560Zm720 0q-17 0-29-11.5T797-600q-8-57-33-106t-64-86q-13-12-15-28t8-30q10-14 26.5-15t29.5 10q53 48 87 112.5T878-603q2 17-9.5 30T840-560Z',
'M200-200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-83 50-147.5T420-792v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 84.5T720-560v280h40q17 0 28.5 11.5T800-240q0 17-11.5 28.5T760-200H200ZM480-80q-33 0-56.5-23.5T400-160h160q0 33-23.5 56.5T480-80ZM120-560q-17 0-28.5-13T82-603q8-75 42-139.5T211-855q13-11 29.5-10t26.5 15q10 14 8 30t-15 28q-39 37-64 86t-33 106q-2 17-14 28.5T120-560Zm720 0q-17 0-29-11.5T797-600q-8-57-33-106t-64-86q-13-12-15-28t8-30q10-14 26.5-15t29.5 10q53 48 87 112.5T878-603q2 17-9.5 30T840-560Z'],
notifications_off: [
'M646-200H200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-33 8.5-65t25.5-61l60 60q-7 16-10.5 32.5T320-560v280h248L84-764q-11-11-11-28t11-28q11-11 28-11t28 11l680 680q11 11 11.5 27.5T820-84q-11 11-28 11t-28-11L646-200ZM540-792q80 20 130 84.5T720-560v110q0 20-12.5 30T680-410q-15 0-27.5-10.5T640-451v-109q0-66-47-113t-113-47q-16 0-34 4t-32 10q-17 7-33.5 3T355-722q-8-13-5.5-27.5T365-771q13-7 27-12t28-9v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28Zm-96 388Zm36 324q-30 0-53.5-16.5T403-141q0-8 6.5-13.5T424-160h112q8 0 14.5 5.5T557-141q0 28-23.5 44.5T480-80Zm33-481Z',
'M646-200H200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-33 8.5-65t25.5-61l126 126H288L84-764q-11-11-11-28t11-28q11-11 28-11t28 11l680 680q11 11 11.5 27.5T820-84q-11 11-28 11t-28-11L646-200Zm74-251q0 12-7 22t-18 15q-11 5-23 2.5T652-422L367-707q-7-7-10-15t-3-17q0-11 5.5-21.5T375-776q11-5 22-9t23-7v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 85t50 147v109ZM480-80q-30 0-53.5-16.5T403-141q0-8 6.5-13.5T424-160h112q8 0 14.5 5.5T557-141q0 28-23.5 44.5T480-80Z'],
open_in_full: [
'M160-120q-17 0-28.5-11.5T120-160v-240q0-17 11.5-28.5T160-440q17 0 28.5 11.5T200-400v144l504-504H560q-17 0-28.5-11.5T520-800q0-17 11.5-28.5T560-840h240q17 0 28.5 11.5T840-800v240q0 17-11.5 28.5T800-520q-17 0-28.5-11.5T760-560v-144L256-200h144q17 0 28.5 11.5T440-160q0 17-11.5 28.5T400-120H160Z',
'M160-120q-17 0-28.5-11.5T120-160v-240q0-17 11.5-28.5T160-440q17 0 28.5 11.5T200-400v144l504-504H560q-17 0-28.5-11.5T520-800q0-17 11.5-28.5T560-840h240q17 0 28.5 11.5T840-800v240q0 17-11.5 28.5T800-520q-17 0-28.5-11.5T760-560v-144L256-200h144q17 0 28.5 11.5T440-160q0 17-11.5 28.5T400-120H160Z'],
@@ -245,6 +270,9 @@
playlist_add: [
'M160-320q-17 0-28.5-11.5T120-360q0-17 11.5-28.5T160-400h200q17 0 28.5 11.5T400-360q0 17-11.5 28.5T360-320H160Zm0-160q-17 0-28.5-11.5T120-520q0-17 11.5-28.5T160-560h360q17 0 28.5 11.5T560-520q0 17-11.5 28.5T520-480H160Zm0-160q-17 0-28.5-11.5T120-680q0-17 11.5-28.5T160-720h360q17 0 28.5 11.5T560-680q0 17-11.5 28.5T520-640H160Zm520 480q-17 0-28.5-11.5T640-200v-120H520q-17 0-28.5-11.5T480-360q0-17 11.5-28.5T520-400h120v-120q0-17 11.5-28.5T680-560q17 0 28.5 11.5T720-520v120h120q17 0 28.5 11.5T880-360q0 17-11.5 28.5T840-320H720v120q0 17-11.5 28.5T680-160Z',
'M160-320q-17 0-28.5-11.5T120-360q0-17 11.5-28.5T160-400h200q17 0 28.5 11.5T400-360q0 17-11.5 28.5T360-320H160Zm0-160q-17 0-28.5-11.5T120-520q0-17 11.5-28.5T160-560h360q17 0 28.5 11.5T560-520q0 17-11.5 28.5T520-480H160Zm0-160q-17 0-28.5-11.5T120-680q0-17 11.5-28.5T160-720h360q17 0 28.5 11.5T560-680q0 17-11.5 28.5T520-640H160Zm520 480q-17 0-28.5-11.5T640-200v-120H520q-17 0-28.5-11.5T480-360q0-17 11.5-28.5T520-400h120v-120q0-17 11.5-28.5T680-560q17 0 28.5 11.5T720-520v120h120q17 0 28.5 11.5T880-360q0 17-11.5 28.5T840-320H720v120q0 17-11.5 28.5T680-160Z'],
playlist_add_check: [
'M160-320q-17 0-28.5-11.5T120-360q0-17 11.5-28.5T160-400h240q17 0 28.5 11.5T440-360q0 17-11.5 28.5T400-320H160Zm0-160q-17 0-28.5-11.5T120-520q0-17 11.5-28.5T160-560h400q17 0 28.5 11.5T600-520q0 17-11.5 28.5T560-480H160Zm0-160q-17 0-28.5-11.5T120-680q0-17 11.5-28.5T160-720h400q17 0 28.5 11.5T600-680q0 17-11.5 28.5T560-640H160Zm494 423q-8 0-15-2.5t-13-8.5l-86-86q-11-11-11.5-27.5T540-370q11-11 27.5-11.5T596-371l58 57 141-141q12-12 28.5-11.5T852-454q11 12 11.5 28T852-398L682-228q-6 6-13 8.5t-15 2.5Z',
'M160-320q-17 0-28.5-11.5T120-360q0-17 11.5-28.5T160-400h240q17 0 28.5 11.5T440-360q0 17-11.5 28.5T400-320H160Zm0-160q-17 0-28.5-11.5T120-520q0-17 11.5-28.5T160-560h400q17 0 28.5 11.5T600-520q0 17-11.5 28.5T560-480H160Zm0-160q-17 0-28.5-11.5T120-680q0-17 11.5-28.5T160-720h400q17 0 28.5 11.5T600-680q0 17-11.5 28.5T560-640H160Zm494 423q-8 0-15-2.5t-13-8.5l-86-86q-11-11-11.5-27.5T540-370q11-11 27.5-11.5T596-371l58 57 141-141q12-12 28.5-11.5T852-454q11 12 11.5 28T852-398L682-228q-6 6-13 8.5t-15 2.5Z'],
playlist_play: [
'M160-320q-17 0-28.5-11.5T120-360q0-17 11.5-28.5T160-400h240q17 0 28.5 11.5T440-360q0 17-11.5 28.5T400-320H160Zm0-160q-17 0-28.5-11.5T120-520q0-17 11.5-28.5T160-560h400q17 0 28.5 11.5T600-520q0 17-11.5 28.5T560-480H160Zm0-160q-17 0-28.5-11.5T120-680q0-17 11.5-28.5T160-720h400q17 0 28.5 11.5T600-680q0 17-11.5 28.5T560-640H160Zm511 499q-5 3-10 3t-10-2q-5-2-8-6.5t-3-10.5v-246q0-6 3-10.5t8-6.5q5-2 10-2t10 3l184 122q5 3 7 7.5t2 9.5q0 5-2 9.5t-7 7.5L671-141Z',
'M160-320q-17 0-28.5-11.5T120-360q0-17 11.5-28.5T160-400h240q17 0 28.5 11.5T440-360q0 17-11.5 28.5T400-320H160Zm0-160q-17 0-28.5-11.5T120-520q0-17 11.5-28.5T160-560h400q17 0 28.5 11.5T600-520q0 17-11.5 28.5T560-480H160Zm0-160q-17 0-28.5-11.5T120-680q0-17 11.5-28.5T160-720h400q17 0 28.5 11.5T600-680q0 17-11.5 28.5T560-640H160Zm511 499q-5 3-10 3t-10-2q-5-2-8-6.5t-3-10.5v-246q0-6 3-10.5t8-6.5q5-2 10-2t10 3l184 122q5 3 7 7.5t2 9.5q0 5-2 9.5t-7 7.5L671-141Z'],
@@ -278,6 +306,9 @@
shift: [
'M320-160v-280H204q-26 0-36.5-22.5T173-505l276-337q12-15 31-15t31 15l276 337q16 20 5.5 42.5T756-440H640v280q0 17-11.5 28.5T600-120H360q-17 0-28.5-11.5T320-160Zm80-40h160v-320h111L480-754 289-520h111v320Zm80-320Z',
'M320-160v-280H204q-26 0-36.5-22.5T173-505l276-337q12-15 31-15t31 15l276 337q16 20 5.5 42.5T756-440H640v280q0 17-11.5 28.5T600-120H360q-17 0-28.5-11.5T320-160Z'],
shuffle: [
'M600-160q-17 0-28.5-11.5T560-200q0-17 11.5-28.5T600-240h64l-99-99q-12-12-11.5-28.5T566-396q12-12 28.5-12t28.5 12l97 98v-62q0-17 11.5-28.5T760-400q17 0 28.5 11.5T800-360v160q0 17-11.5 28.5T760-160H600Zm-428-12q-11-11-11-28t11-28l492-492h-64q-17 0-28.5-11.5T560-760q0-17 11.5-28.5T600-800h160q17 0 28.5 11.5T800-760v160q0 17-11.5 28.5T760-560q-17 0-28.5-11.5T720-600v-64L228-172q-11 11-28 11t-28-11Zm-1-560q-11-11-11-28t11-28q11-11 27.5-11t28.5 11l168 167q11 11 11.5 27.5T395-565q-11 11-28 11t-28-11L171-732Z',
'M600-160q-17 0-28.5-11.5T560-200q0-17 11.5-28.5T600-240h64l-99-99q-12-12-11.5-28.5T566-396q12-12 28.5-12t28.5 12l97 98v-62q0-17 11.5-28.5T760-400q17 0 28.5 11.5T800-360v160q0 17-11.5 28.5T760-160H600Zm-428-12q-11-11-11-28t11-28l492-492h-64q-17 0-28.5-11.5T560-760q0-17 11.5-28.5T600-800h160q17 0 28.5 11.5T800-760v160q0 17-11.5 28.5T760-560q-17 0-28.5-11.5T720-600v-64L228-172q-11 11-28 11t-28-11Zm-1-560q-11-11-11-28t11-28q11-11 27.5-11t28.5 11l168 167q11 11 11.5 27.5T395-565q-11 11-28 11t-28-11L171-732Z'],
skip_next: [
'M660-280v-400q0-17 11.5-28.5T700-720q17 0 28.5 11.5T740-680v400q0 17-11.5 28.5T700-240q-17 0-28.5-11.5T660-280Zm-440-35v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T548-480q0 10-4.5 18.5T530-447L282-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Zm80-165Zm0 90 136-90-136-90v180Z',
'M660-280v-400q0-17 11.5-28.5T700-720q17 0 28.5 11.5T740-680v400q0 17-11.5 28.5T700-240q-17 0-28.5-11.5T660-280Zm-440-35v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T548-480q0 10-4.5 18.5T530-447L282-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Z'],
@@ -368,5 +399,17 @@
return true;
}
ns.vrSymbols = { draw, has: name => name in PATHS, NAMES: Object.keys(PATHS) };
// An icon given as a name, or as { name, color (a colour spec: tokens.js resolve, e.g.
// 'primary' or '#ff0033'), filled }: drawn like draw(), in its own colour when it has one
// (e.g. a site's mark that keeps its brand colour in a menu row).
function drawIcon(g, icon, cx, cy, size, filled = false) {
if (!icon || typeof icon === 'string') return draw(g, icon, cx, cy, size, filled);
g.save();
if (icon.color) g.fillStyle = ns.vrTokens.resolve(icon.color);
const ok = draw(g, icon.name, cx, cy, size, icon.filled != null ? !!icon.filled : filled);
g.restore();
return ok;
}
ns.vrSymbols = { draw, drawIcon, has: name => name in PATHS, NAMES: Object.keys(PATHS) };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+148 -32
View File
@@ -17,9 +17,10 @@
// the live moment), extra(id, itemId) (the site's own button `id` was clicked,
// or one of its menu's items; itemId null: its menu just opened), ...and
// whatever the site's panels use,
// state() -> { title, time, duration, paused,
// state() -> { title, time, duration, paused, playable (false: the site won't play
// this video: Play greys out),
// nowPlaying (what the controls panel shows: see panel-ui.js),
// quality: { current, currentLabel, levels: [{ id, label, icons? }] },
// quality: { current, currentLabel, levels: [{ id, label, icons?, disabled? }] },
// live: null | { atHead, behind (seconds), dvr } (a live stream; time
// and duration then cover only the rewindable part),
// speed, speeds: [rates], volume: { level, muted },
@@ -27,10 +28,12 @@
// intensity (0..1) }] (how much each part is rewatched; optional),
// ad: null | { ... },
// format: { current, options: [{ id, label, short, disabled }] },
// extras: { left: [...], right: [...] } (the site's own buttons on
// extras: { left: [...], right: [...], seek } (the site's own buttons on
// the controls panel: panel-ui.js, plus menu: null | { items: [{ id,
// text, on, disabled }], note, scroll, width, stay (stays open on a
// choice) }: opens like Quality's),
// choice) }: opens like Quality's, and teaser: null | { card, label }
// (a card floating above the button for a while, like the hover
// card: e.g. an info card as its time comes)),
// ...and what the site's panels read } }
// The layout (docs/Spatial-Layout.md) is placed around the Safe Zone (safe-zone.js), which
// keeps the video clear: each registered panel belongs to a zone (its registration's
@@ -44,7 +47,10 @@
// Which side panels are open is remembered. Each panel is a style (styles/) filled by its
// controller; its control ids are '<panel id>|<style id>' here, and a used control
// reaches the controller as an event { type, value } (ARCHITECTURE.md).
// panel.place(headPosition, yaw) lay it out around you (on enter and recenter)
// panel.place(headPosition, yaw, pitch) lay it out around you (on enter, recenter, and the lazy follow:
// pitch, radians up, 0 otherwise)
// panel.engaged(gaze) -> bool a laser on a panel or handle, a grab / drag going on, or
// (gaze: { origin, dir }) looking at a shown panel
// panel.resetSettings() the global reset: keyboard size back to default, panels shown
// panel.toggleVisible()
// panel.toggleKeyboard() swap the in-VR keyboard (keyboard.js) and the controls panel, in the same place
@@ -127,7 +133,18 @@
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 }.
// opts.below: a dropdown under the control instead of a menu rising above it.
openMenu: (local, opts) => { const id = `p:${reg.id}${SEP}${local}`; menu = menu === id ? null : id; menuBelow = !!(menu && opts && opts.below); lastActivity = performance.now(); },
// Bring the panels back if auto-hide (or the left D-pad) hid them, as a skippable ad
// does: for something that just came up in this panel (e.g. an end screen).
reveal: () => { visible = true; lastActivity = performance.now(); dismissedAt = -1; },
confirm: d => confirm(d),
prompt: d => prompt(d), // a line of text typed into a dialog -> Promise of text | null
typeInto: (on, opts) => typeInto(p, on, opts),
});
panels.set(reg.id, p);
@@ -144,7 +161,7 @@
let typing = null; // the panel being typed into, or null
const TYPING_KEYS = /^(Backspace|Enter|Escape)$/;
// (Typing into the top panel's bar needs only the panels shown: the bar is always there.)
const typingNow = () => !!typing && (typing.zone === 'top' ? visible : shown(typing));
const typingNow = () => !!typing && (typing.zone === 'dialog' ? !!dialog : typing.zone === 'top' ? visible : shown(typing));
// A panel asks for the keys (on) or gives them back. Asking also brings up the in-VR
// keyboard if it isn't up; giving them back (with no other panel typing) puts it away again
// if it came up that way. A panel asking while another has the keys takes them over, and
@@ -174,6 +191,12 @@
const SEP = '|';
const panelOf = id => (id && id.includes(SEP) ? panels.get(id.slice(0, id.indexOf(SEP))) : null);
const localOf = (id, p) => (id && id.startsWith(p.reg.id + SEP) ? id.slice(p.reg.id.length + 1) : null);
// A side panel's menu id ('p:<panel>|<control>', host.openMenu) -> { p, local } or null.
function panelMenu(id) {
if (!id || !id.startsWith('p:')) return null;
const rest = id.slice(2), i = rest.indexOf(SEP), p = i < 0 ? null : panels.get(rest.slice(0, i));
return p ? { p, local: rest.slice(i + 1) } : null;
}
// Tell a panel's controller one of its controls was used: '<type>:<value>' -> event.
function sendEvent(p, local, extra) {
const i = local.indexOf(':');
@@ -185,14 +208,15 @@
let hoverImages = 0;
const hoverCard = ns.vrHoverCardUI.create(() => { hoverImages++; });
const hoverPass = ns.vrPanelPass.createPanelPass(gl, vaos, hoverCard.canvas);
let hoverShown = null; // { rect (controls canvas px) } while a card is up
let hoverShown = null; // { rect (controls canvas px), teaser (the site button's id, a teaser: clickable) } while a card is up
let hoverKey = '';
// ------------------------------------------------------------ popups and dialogs
// The controls panel's menus float in front of it (32 dp, Android XR's popup level),
// rising from the button that opened them (popups-ui.js createMenu); a dialog (confirm()
// below) stands in front of everything (56 dp) over a dim layer (the scrim).
const menuUi = ns.vrPopupsUI.createMenu();
let menuImages = 0; // bumps when a menu row's picture arrives (redraw)
const menuUi = ns.vrPopupsUI.createMenu(() => { menuImages++; });
const menuPass = ns.vrPanelPass.createPanelPass(gl, vaos, menuUi.canvas), menuSize = metres(menuUi.canvas);
let menuKey = '', menuShown = null; // what was last drawn; the menu last opened (it fades out from its button)
const dialogUi = ns.vrPopupsUI.createDialog();
@@ -212,21 +236,72 @@
// true for confirm, false for cancel or a click anywhere else (a new dialog cancels the
// one before). The panels show (and stay) while it's up.
function confirm(d) {
if (dialog) dialog.resolve(false);
if (dialog) closeDialog(false); // (a new dialog cancels the one before)
return new Promise(resolve => {
dialog = { d: { cancel: tr('dialog.cancel'), ...d }, resolve };
dialogShown = dialog.d; visible = true; menu = null; lastActivity = performance.now();
});
}
// Ask for a line of text in a dialog: d = { title, text, placeholder, value (to start
// with), confirm (its button's label), cancel }. The dialog has a text field that takes
// the keys (the in-VR keyboard comes up for it; Enter confirms, Escape cancels). Resolves
// the text (trimmed) for confirm, or null for cancel or a click anywhere else.
const dialogTyping = {
zone: 'dialog', reg: { id: 'dialog', label: () => (dialog && dialog.d.title) || '' },
ctrl: { event(ev) {
const f = dialog && dialog.d.field;
if (!f || ev.type !== 'key') return;
const k = ev.value;
if (k === 'Enter') { if (f.text.trim()) closeDialog(true); }
else if (k === 'Escape') closeDialog(false);
else if (k === 'Backspace') dialog.d = { ...dialog.d, field: { ...f, text: f.text.slice(0, -1) } };
else if (k.length === 1 && f.text.length < 150) dialog.d = { ...dialog.d, field: { ...f, text: f.text + k } };
} },
};
function prompt(d) {
const p = confirm({ ...d, field: { text: d.value || '', placeholder: d.placeholder || '' } });
typeInto(dialogTyping, true);
return p;
}
function closeDialog(answer) {
if (!dialog) return;
const r = dialog.resolve; dialog = null; lastActivity = performance.now();
r(answer);
const r = dialog.resolve, f = dialog.d.field; dialog = null; lastActivity = performance.now();
if (f) typeInto(dialogTyping, false);
r(f ? (answer && f.text.trim() ? f.text.trim() : null) : answer);
}
// 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 && menuBelow) {
// A dropdown: the menu's card (at the bottom of its canvas, centred across it) with its
// top edge 12 px under the control, centred on it, 32 dp in front. Kept inside the
// panel's width, and clear of the side's toolbar, which stands over the panel's inner
// edge (half its bar over the panel) and would sit behind the card.
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 card = menuUi.cardRect(), mw = menuUi.canvas.width, mh = menuUi.canvas.height;
const k = menuSize[1] / mh, cardW = (card ? card.w : mw) * k;
const o = orbiters[pm.p.zone], bar = o ? (o.ui.barWidth / 2) * MPX + dp(8) : 0;
const lo = -sz[0] / 2 + cardW / 2 + (pm.p.zone === 'right' ? bar : 0);
const hi = sz[0] / 2 - cardW / 2 - (pm.p.zone === 'left' ? bar : 0);
const want = ((r.x + r.w / 2) / c.width - 0.5) * sz[0];
const x = lo <= hi ? Math.max(lo, Math.min(hi, want)) : (lo + hi) / 2;
const below = (0.5 - (r.y + r.h) / c.height) * sz[1] - 12 * MPX;
const top = (mh / 2 - (card ? card.y : 0)) * k; // the card's top edge, up from the canvas's centre
return M.multiply(panelModel(pm.p), M.translation(x, below - top, dp(32)));
}
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];
@@ -396,10 +471,12 @@
let grab = null; // { hand, local } while moving the panel by its grab bar
let sideGrab = null; // { hand, side, local, at } while moving a side by its toolbar's handle (moveSide)
let menu = null; // null | 'quality' | 'speed' | 'format' | 'volume' | 'x:<id>' (a site button's)
let menuBelow = false; // a panel's menu opened as a dropdown under its control (host.openMenu opts.below)
let extrasNow = null; // the site's own buttons, from the latest state (st.extras)
let menuDrag = null; // { hand, v, scroll, moved } while a trigger drags a scrolling menu
let muted = false; // last seen mute state (for the Mute/Unmute button)
let adSkippable = false; // last seen: the current ad can be skipped
let teaserWas = false; // last seen: a site button had a teaser up
let targets = []; // other grabbable things (see setTargets)
let targetGrab = null; // { hand, target } while gripping one of them
let drawnKey = '';
@@ -514,10 +591,10 @@
// (the controls panel, the sides) comes along: its move is turned and shifted as the
// recenter turned and shifted the layout, so it stays where it was relative to you. (With
// Settings → Safe zone → "Recenter resets panels" on, the moves are dropped instead.)
const anchorFrame = () => { const a = ns.vrSafeZone.anchor; return M.multiply(M.translation(a.pos[0], a.pos[1], a.pos[2]), M.rotationY(a.yaw)); };
function place(head, yaw) {
const anchorFrame = () => { const a = ns.vrSafeZone.anchor; return M.multiply(M.translation(a.pos[0], a.pos[1], a.pos[2]), M.multiply(M.rotationY(a.yaw), M.axisAngle(1, 0, 0, a.pitch || 0))); };
function place(head, yaw, pitch = 0) {
const before = anchorFrame();
ns.vrSafeZone.setAnchor(head, yaw);
ns.vrSafeZone.setAnchor(head, yaw, pitch);
const turn = M.multiply(anchorFrame(), M.invertRigid(before)); // the old anchor's frame -> the new one's
const drop = ns.vrSafeZoneOptions && ns.vrSafeZoneOptions.recenterResets();
const carry = mv => (mv && !drop ? M.multiply(turn, M.multiply(mv, M.invertRigid(turn))) : null);
@@ -640,6 +717,13 @@
function click(id) {
const p = panelOf(id);
if (p) { sendEvent(p, localOf(id, p)); return; }
if (id.startsWith('pi:')) { // an item of a side panel's menu: 'pi:<index>'
const pm = panelMenu(menu), mm = pm && pm.p.ctrl.menu ? pm.p.ctrl.menu(pm.local) : null;
const it = mm && (mm.items || [])[+id.slice(3)];
if (it && !it.disabled) pm.p.ctrl.event({ type: 'menu', value: it.id, menu: pm.local });
if (!(mm && mm.stay)) menu = null;
return;
}
if (id.startsWith('toggle:')) {
const pid = id.slice(7);
toggleOpen(pid);
@@ -673,10 +757,12 @@
const rest = id.slice(3), i = rest.indexOf(':'), xid = rest.slice(0, i), x = extraOf(xid);
if (actions.extra) actions.extra(xid, rest.slice(i + 1));
if (!(x && x.menu && x.menu.stay)) menu = null;
} else if (id.startsWith('teaser:')) { // a site button's teaser card: 'teaser:<button>'
if (actions.extra) actions.extra(id.slice(7), 'teaser');
}
}
// A site button as the button row draws it (without its menu).
const stripMenu = ({ menu: _m, ...x }) => x;
const stripMenu = ({ menu: _m, teaser: _t, ...x }) => x; // (menus and teasers aren't drawn on the panel)
// One of the site's own buttons (st.extras) by id, or null.
const extraOf = xid => [...((extrasNow && extrasNow.left) || []), ...((extrasNow && extrasNow.right) || [])].find(x => x.id === xid) || null;
@@ -716,7 +802,7 @@
// (press, scrub/slide/drag while held, release = click) and the grip (grab the panel's
// bar or a handle), and set up the beam to draw;
// 3. show the cinema screen's handles while the panel is up;
// 4. ad handling (no scrubbing; pop up when Skip appears) and auto-hide;
// 4. ad handling (no scrubbing; pop up when Skip appears, or a teaser) and auto-hide;
// 5. redraw each canvas only if something it shows has changed.
let lastUpdate = 0; // the previous update's time (ms), for frameDt
let frameDt = 0; // seconds since the previous update (capped)
@@ -822,7 +908,7 @@
// What is this laser on? The panel (only while shown) or another target; nearest wins.
const inside = r => !!(r && r.u >= 0 && r.u <= 1 && r.v >= 0 && r.v <= 1);
const mainHit = visible && !kbShown && !dialog ? intersect(origin, dir) : null; // (the keyboard stands in for it; a dialog is modal)
// Which surface the laser is on (nearest if several): 'main', 'orbit:<left | right |
// Which surface the laser is on (nearest if several): 'main', 'teaser', 'orbit:<left | right |
// pill | bar>', 'menu', 'dialog', 'keyboard' or a panel id. The orbiters, the menu and the
// keyboard only where they're drawn (the rest of their canvas lets the laser through).
const hits = { main: mainHit };
@@ -832,6 +918,10 @@
const r = intersect(origin, dir, orbiterModel(name), o.size);
if (inside(r) && o.ui.solidAt(r.u, r.v)) hits[`orbit:${name}`] = r;
}
if (hoverShown && hoverShown.teaser && !kbShown) { // a teaser card (clickable, unlike the hover card)
const r = intersect(origin, dir, hoverCardModel(hoverShown.rect), hoverSize);
if (inside(r)) hits.teaser = r;
}
if (menu && !kbShown) { // the floating menu (on its card only)
const mm = menuModel(menu), r = mm && intersect(origin, dir, mm, menuSize);
if (inside(r) && menuUi.solidAt(r.u, r.v)) hits.menu = r;
@@ -842,10 +932,12 @@
const r = intersect(origin, dir, kbModel(), kbSize());
if (inside(r) && kb.solidAt(r.u, r.v)) hits.keyboard = r;
}
if (dialog) { // a dialog is modal: only it can be pointed at
if (dialog) { // a dialog is modal: only it (and the keyboard, for its text field) can be pointed at
const kbHit = dialog.d.field ? hits.keyboard : null;
for (const k of Object.keys(hits)) delete hits[k];
const r = intersect(origin, dir, dialogModel(dp(56)), dialogSize);
if (inside(r)) hits.dialog = r;
if (kbHit) hits.keyboard = kbHit;
}
let surface = null;
for (const [name, r] of Object.entries(hits)) {
@@ -874,6 +966,8 @@
target = r ? { id: `kb:${r.id}` } : null;
} else if (onPanel && surface === 'menu') {
target = menuUi.hitTest(hit.u, hit.v);
} else if (onPanel && surface === 'teaser') {
target = { id: `teaser:${hoverShown.teaser}` };
} else if (onPanel && surface === 'dialog') {
target = dialogUi.hitTest(hit.u, hit.v);
} else if (onPanel && surface === 'orbit:bar') {
@@ -1048,6 +1142,10 @@
const canSkip = !!(ad && ad.skippable && ad.canSkipInVR);
if (canSkip && !adSkippable) { visible = true; lastActivity = now; }
adSkippable = canSkip;
// Likewise when a site button's teaser comes up (e.g. an info card), and they stay while it shows.
const teaserUp = !ad && !!extrasNow && [...(extrasNow.left || []), ...(extrasNow.right || [])].some(x => x.teaser && !x.disabled);
if (teaserUp && !teaserWas) { visible = true; lastActivity = now; }
teaserWas = teaserUp;
// Auto-hide while playing and nobody is using it (typing into a panel, or the keyboard
// being up, counts as using it), each part after its own wait (Settings → Auto-hide,
@@ -1059,7 +1157,7 @@
const F = ns.vrFadeOptions, unused = now - lastActivity;
if (dismissedAt >= 0 && lastActivity > dismissedAt) dismissedAt = -1;
const playing = !st.paused || (F.hideWhilePaused() && st.duration > 0);
const idle = playing && !scrub && !setDrag && !pressed && !grab && !sideGrab && !menu && !dialog && !canSkip && !typingNow() && !kbShown;
const idle = playing && !scrub && !setDrag && !pressed && !grab && !sideGrab && !menu && !dialog && !canSkip && !teaserUp && !typingNow() && !kbShown;
if (visible && idle && unused > F.seconds('panels') * 1000) visible = false;
barsAwake = dismissedAt < 0 && !(idle && unused > F.seconds('bars') * 1000);
searchAwake = dismissedAt < 0 && !(idle && unused > F.seconds('search') * 1000);
@@ -1087,12 +1185,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 })),
items: quality.levels.map(q => ({ id: `q:${q.id}`, text: q.label, on: q.id === quality.current, icons: q.icons, disabled: !!q.disabled })),
note: quality.levels.length ? null : tr('panel.noQualities', { site: site.name || tr('panel.theSite') }),
};
} else if (menu === 'format') {
menuState = {
items: format.options.map(o => ({ id: `f:${o.id}`, text: o.short, on: o.id === format.current, disabled: o.disabled })),
items: format.options.map(o => ({ id: `f:${o.id}`, text: o.short, on: o.id === format.current, disabled: o.disabled, icons: [ns.vrPanelUI.formatIcon(o.id)] })),
note: null,
};
} else if (menu === 'speed') {
@@ -1102,6 +1200,10 @@
const x = extraOf(menu.slice(2)), mm = x && x.menu;
if (mm) menuState = { ...mm, key: menu, items: (mm.items || []).map(it => ({ ...it, id: `xi:${x.id}:${it.id}` })) };
else menu = null; // the button went away
} else if (panelMenu(menu)) { // a side panel's menu (its items' ids: 'pi:<index>')
const pm = panelMenu(menu), mm = shown(pm.p) && pm.p.ctrl.menu ? pm.p.ctrl.menu(pm.local) : null;
if (mm) menuState = { ...mm, key: menu, items: (mm.items || []).map((it, i) => ({ ...it, id: `pi:${i}` })) };
else menu = null; // the panel closed, or has no menu there now
}
const volume = st.volume || { level: 100, muted: false };
muted = !!volume.muted;
@@ -1119,16 +1221,16 @@
}
const state = {
title: st.title, time: st.time, duration: st.duration, paused: st.paused, buffered: st.buffered != null ? st.buffered : null,
title: st.title, time: st.time, duration: st.duration, paused: st.paused, playable: st.playable !== false, buffered: st.buffered != null ? st.buffered : null,
live: st.live || null, nowPlaying: st.nowPlaying || null, now,
hover, pressed: pressed && pressed.id, scrubTime: scrub ? scrub.time : null,
quality, format: { id: format.current, label: currentFormat ? currentFormat.short : '' },
speed: st.speed || 1, volume: shownVolume, chapters: st.chapters || [], heatmap: st.heatmap || [], preview, ad,
menuOpen: menu, grabbing: !!grab,
extras: extrasNow ? { left: (extrasNow.left || []).map(stripMenu), right: (extrasNow.right || []).map(stripMenu) } : null,
extras: extrasNow ? { left: (extrasNow.left || []).map(stripMenu), right: (extrasNow.right || []).map(stripMenu), seek: extrasNow.seek || null } : null,
};
const pf = preview && preview.frame;
const key = [state.title, Math.floor(state.time), Math.floor(state.duration), state.paused, state.buffered != null ? Math.floor(state.buffered) : '',
const key = [state.title, Math.floor(state.time), Math.floor(state.duration), state.paused, state.playable, state.buffered != null ? Math.floor(state.buffered) : '',
JSON.stringify(state.nowPlaying), JSON.stringify(state.extras), state.hover, state.pressed, scrub ? Math.round(scrub.time * 4) : '',
quality.current, quality.levels.map(q => q.id).join(','), format.current, menu, !!grab,
state.speed, Math.round(shownVolume.level), shownVolume.muted, state.chapters.length, state.heatmap.length,
@@ -1142,7 +1244,7 @@
// at on them, changes, and every frame while something on them eases).
if (menuState) {
menuShown = menu;
const mKey = [JSON.stringify(menuState), hover, pressed && pressed.id, Math.round(menuUi.shownScroll()), ns.i18n.version(), ns.vrTokens.glass.version, menuUi.moving() ? now : ''].join('|');
const mKey = [JSON.stringify(menuState), hover, pressed && pressed.id, Math.round(menuUi.shownScroll()), menuImages, ns.i18n.version(), ns.vrTokens.glass.version, menuUi.moving() ? now : ''].join('|');
if (mKey !== menuKey) { menuUi.draw(menuState, { hover, pressed: pressed && pressed.id }); menuPass.upload(); menuKey = mKey; }
}
if (dialog) {
@@ -1151,13 +1253,16 @@
}
// Hover card: pointing at Previous / Next in a list shows the card it leads to
// (nowPlaying.queue.prev / next, cards made by the provider).
// (nowPlaying.queue.prev / next, cards made by the provider); otherwise a site
// button's teaser floats above that button (extras' teaser: { card, label }).
const q = state.nowPlaying && state.nowPlaying.queue;
const cardItem = q && !ad ? (hover === 'pl:prev' ? q.prev : hover === 'pl:next' ? q.next : null) : null;
const cardRect = cardItem && ui.rectOf(hover);
hoverShown = cardRect ? { rect: cardRect } : null;
let cardItem = q && !ad ? (hover === 'pl:prev' ? q.prev : hover === 'pl:next' ? q.next : null) : null;
let cardAt = hover, label = hover === 'pl:prev' ? tr('panel.previousVideo') : tr('panel.nextVideo');
const teasing = !cardItem && !ad && !menu && [...((extrasNow && extrasNow.left) || []), ...((extrasNow && extrasNow.right) || [])].find(x => x.teaser && !x.disabled);
if (teasing) { cardItem = teasing.teaser.card; cardAt = `x:${teasing.id}`; label = teasing.teaser.label || ''; }
const cardRect = cardItem && ui.rectOf(cardAt);
hoverShown = cardRect ? { rect: cardRect, teaser: teasing ? teasing.id : null } : null;
if (hoverShown) {
const label = hover === 'pl:prev' ? tr('panel.previousVideo') : tr('panel.nextVideo');
const card = cardItem;
const hKey = [JSON.stringify(card), label, hoverImages, ns.vrTokens.glass.version].join('|');
if (hKey !== hoverKey) { hoverCard.draw(card, label); hoverPass.upload(); hoverKey = hKey; }
@@ -1280,7 +1385,9 @@
}
if (hoverShown && !kbShown && cpA > 0.01) items.push([hoverPass, hoverCardModel(hoverShown.rect), hoverSize, null, cpA]);
// The floating menu, growing up from its button (its canvas's bottom edge).
const MA = Math.min(1, Math.max(0, anim.menu.get())) * cpA, mm = MA > 0.01 && menuShown ? menuModel(menuShown) : null;
// (A side panel's menu fades with its panel's side instead of the controls panel.)
const menuA = panelMenu(menuShown) ? (panelMenu(menuShown).p.reg.stays ? 1 : V * B) : cpA;
const MA = Math.min(1, Math.max(0, anim.menu.get())) * menuA, mm = MA > 0.01 && menuShown ? menuModel(menuShown) : null;
if (mm) items.push([menuPass, scaled(mm, [0, -menuSize[1] / 2], shrink(anim.menu.get())), menuSize, backdrop, MA]);
// Settled ones first (writing depth), then the ones still fading.
items.sort((a, b) => (b[4] > 0.99) - (a[4] > 0.99));
@@ -1333,7 +1440,16 @@
for (const p of panels.values()) p.pass.dispose();
}
return { place, resetSettings, toggleVisible, toggleKeyboard, claimsGrip, stickScrolls, stickPages, stickPage, iconSwaps, setTargets, update, drawPanel, drawLasers, dispose,
// Is something being used now: a laser on a panel or a handle, a grab, a drag or the seek bar
// held, or (gaze: { origin, dir }, your head's forward ray) are you looking at a shown panel?
// (The cinema screen's lazy follow waits meanwhile: xr-session.js.)
const onIt = r => !!(r && r.u >= 0 && r.u <= 1 && r.v >= 0 && r.v <= 1);
const engaged = gaze => !!(grab || sideGrab || kbGrab || targetGrab || setDrag || textDrag || menuDrag || scrub
|| [...hands.values()].some(h => h.hover || h.targetHit)
|| (gaze && ((visible && !kbShown && onIt(intersect(gaze.origin, gaze.dir)))
|| [...panels.values()].some(p => shown(p) && onIt(intersect(gaze.origin, gaze.dir, panelModel(p), p.size))))));
return { place, engaged, resetSettings, toggleVisible, toggleKeyboard, claimsGrip, stickScrolls, stickPages, stickPage, iconSwaps, setTargets, update, drawPanel, drawLasers, dispose,
isVisible: () => visible, isKeyboardShown: () => kbShown };
}
+136 -19
View File
@@ -1,12 +1,15 @@
// YouTube's feed panels, both in the Catalog style: Explore (YouTube's Home feed with its
// topic chips, then the Explore pages of YouTube's left menu) and You (your own feeds, signed
// in: Subscriptions, Your channel, History, Playlists, Watch later, Liked videos, Movies &
// in: Subscriptions, Channels (the ones you subscribe to: a click opens one in the Channel
// panel), Your channel, History, Playlists, Watch later, Liked videos, Movies &
// TV). Each shows its sources as tabs (the row slides when they don't all fit), the loaded
// videos (a pager that loads more pages as you page through) and the current page. A source
// that isn't built yet shows "Coming soon" with the construction icon. A source loads the
// first time it shows. Panel controllers: see panels.js.
// actions used: browseHome(chip) -> pager (youtube-home.js home(); p.chips after a page),
// browseSubscriptions() -> pager (youtube-subscriptions.js feed()), browseFeed(name) ->
// browseSubscriptions() -> pager (youtube-subscriptions.js feed()), browseChannels() ->
// pager (youtube-subscriptions.js channels()), browseSections(name) -> pager of sections
// (youtube-home.js feed(): the Explore pages), 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
@@ -17,29 +20,110 @@
const IMMERSIVE = 'Immersive videos'; // YouTube's 360°/VR chip: shown right after "All"
const tr = (key, vars) => ns.i18n.t(key, vars); // words in the interface language (strings-en.js)
// A feed of titled sections (an Explore page: Live's "Live Now", "Upcoming Live Streams" ...)
// as one list, offering its sections as choices (p.sorts: "All" first, then each section's
// title in the order they arrive; chip: the section to show, ALL or none for every one).
// All: the sections run together; the first time, up to PRELOAD more pages are read at once
// so sections that only come later (e.g. Gaming's game shelves) are offered too. A chosen
// section with a "View all" (youtube-home.js viewAllOf) shows that whole list instead, page
// by page; one without stops when later pages bring nothing more of it (SECTION_TRIES).
// tab: the page's own tab to read (News: "Top stories", "Sports" ...), or none.
const ALL = '*', SECTION_TRIES = 3, PRELOAD = 3;
function sectionList(a, name, chip, tab) {
const inner = a.browseSections(name, tab), want = chip && chip !== ALL ? chip : null;
const sections = [];
let dry = 0, full = null, first = true;
const p = {
items: [], done: false, message: '', sorts: [],
async next() {
if (full) { await full.next(); p.items = full.items; p.done = full.done; return; }
sections.push(...((await inner.next()) || []));
if (first && !want) for (let i = 0; i < PRELOAD && !inner.done; i++) sections.push(...((await inner.next()) || []));
first = false;
const titles = [...new Set(sections.map(s => s.title).filter(Boolean))];
p.sorts = [{ text: ALL, label: tr('yt.browse.all'), selected: !want },
...titles.map(t => ({ text: t, selected: t === want }))];
p.message = inner.message || '';
// The chosen section's whole list, where it has one.
const chosen = want && sections.find(s => s.title === want && s.viewAll);
if (chosen) {
full = a.browseViewAll(chosen.viewAll);
await full.next(); p.items = full.items; p.done = full.done;
return;
}
const before = p.items.length;
p.items = sections.filter(s => !want || s.title === want).flatMap(s => s.items);
dry = want && p.items.length === before ? dry + 1 : 0;
p.done = inner.done || dry >= SECTION_TRIES;
},
};
return p;
}
// The News page as one list, its own tabs (Top stories, Sports, Business ...) offered as the
// choices (p.sorts, the one shown selected; chip: a tab's title, none: YouTube's first). A
// tab's sections run together (its posts left out).
function newsList(a, chip) {
const inner = a.browseSections('news', chip && chip !== ALL ? chip : undefined);
const p = {
items: [], done: false, message: '', sorts: [],
async next() {
const got = (await inner.next()) || [];
p.items = [...p.items, ...got.flatMap(s => s.items)];
p.sorts = (inner.tabs || []).map(t => ({ text: t.title, selected: !!t.selected }));
p.message = inner.message || ''; p.done = inner.done;
},
};
return p;
}
// A panel's sources, in YouTube's left-menu order. Each: id (its name is 'yt.browse.<id>'
// in the string tables, and with `note` the grey line under the tabs 'yt.browse.<id>Note'),
// icon (Material Symbols, symbols.js), and either load(actions, chip) (makes the pager;
// `list` is the playlist its videos play in) with optional chips, or soon (not built yet:
// "Coming soon"). chips: 'home' (the Home feed's topic chips, from its pager), or a fixed
// list of chip ids (named 'yt.channel.<id>'), the first one chosen at first.
// "Coming soon"). card (optional): how an item becomes a card (default: a video card);
// pick(host, item) (optional): what clicking one does (default: play it). chips: 'home'
// (the Home feed's topic chips, from its pager), 'sorts' (the choices its pager offers:
// p.sorts: [{ text (its id), label (optional), selected }], e.g. sorts or sections), 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 },
{ id: 'music', icon: 'music_note', soon: true },
{ id: 'music', icon: 'music_note', load: (a, chip) => sectionList(a, 'music', chip), chips: 'sorts' },
{ id: 'movies', icon: 'movie', soon: true },
{ id: 'hype', icon: 'local_fire_department', soon: true },
{ id: 'live', icon: 'sensors', soon: true },
{ id: 'gaming', icon: 'sports_esports', soon: true },
{ id: 'news', icon: 'newspaper', load: a => a.browseFeed('news') },
{ id: 'hype', icon: 'local_fire_department', load: (a, chip) => sectionList(a, 'hype', chip), chips: 'sorts' },
{ id: 'live', icon: 'sensors', load: (a, chip) => sectionList(a, 'live', chip), chips: 'sorts' },
{ id: 'gaming', icon: 'sports_esports', load: (a, chip) => sectionList(a, 'gaming', chip), chips: 'sorts' },
{ id: 'news', icon: 'newspaper', load: (a, chip) => newsList(a, chip), chips: 'sorts' },
{ id: 'learning', icon: 'lightbulb', soon: true },
{ id: 'courses', icon: 'school', soon: true },
{ id: 'podcasts', icon: 'podcasts', soon: true },
{ id: 'memberships', icon: 'card_membership', soon: true },
{ id: 'courses', icon: 'school', load: (a, chip) => sectionList(a, 'courses', chip), chips: 'sorts' },
{ id: 'podcasts', icon: 'podcasts', load: (a, chip) => sectionList(a, 'podcasts', chip), chips: 'sorts' },
{ id: 'memberships', icon: 'card_membership', load: (a, chip) => sectionList(a, 'memberships', chip), chips: 'sorts' },
];
// 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).
// hint: what's known of it (default: one of your subscriptions, with its bell, from the list).
function showChannel(host, c, hint = { subscribed: true, notifications: c.notifications }) {
const ch = host.panel && host.panel('channel');
if (ch && ch.show) ch.show(c.id, hint);
}
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 },
@@ -79,21 +163,28 @@
// A feed panel: `title` its name's string key, `sources` (above) its tabs.
function createFeedPanel(host, title, sources) {
const { actions } = host;
const perPage = host.perPage;
// The list's layout: square cards when most items have a square picture (podcasts' covers:
// the others then show their picture whole in the square), else the standard ones; and how
// many cards that layout fits on a page.
const shapeNow = () => (items().filter(it => it.square).length * 2 > items().length ? 'square' : null);
const perPage = () => (host.style.perPageFor ? host.style.perPageFor(shapeNow()) : 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 choices its pager offers, [{ id, label }] (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;
const playlist = ns.ytVr.playlist.create(actions); // a playlist card opened in place of the list (playlist.js)
const bump = () => { ver++; };
const current = () => sources.find(s => s.id === source);
const items = () => (pager ? pager.items : []);
const pageCount = () => Math.max(1, Math.ceil(items().length / perPage));
const pageCount = () => Math.max(1, Math.ceil(items().length / perPage()));
const tabs = () => sources.map(({ id, icon }) => ({ id, label: tr(`yt.browse.${id}`), icon }));
// 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] || [];
return (availOf[s.id] || s.chips || []).map(id => ({ id, label: tr(`yt.channel.${id}`) }));
}
const chipNow = (s = current()) => chipOf[s.id] || (Array.isArray(s.chips) ? s.chips[0] : null);
@@ -113,6 +204,16 @@
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 choices (the Channels list's sorts, a feed's sections): offered as its pills, the one
// it shows chosen. Choices already offered stay, in place, with new ones after them: a
// chosen section reads fewer pages, so it would offer fewer (and the pill row, its
// labels changed, would slide back to its start).
if (p === pager && current().chips === 'sorts' && p.sorts && p.sorts.length) {
const known = sortsOf[source] || [];
const fresh = p.sorts.map(x => ({ id: x.text, label: x.label || x.text }));
sortsOf[source] = [...known, ...fresh.filter(f => !known.some(k => k.id === f.id))];
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 }); })
@@ -132,6 +233,11 @@
}
function event(ev) {
// An open playlist takes its own controls; a tab (the same one too) closes it.
if (playlist.isOpen()) {
if (ev.type === 'tab') { playlist.close(); if (ev.value === source) return; }
else if (playlist.event(ev)) return;
}
if (ev.type === 'tab') {
if (ev.value !== source && sources.some(x => x.id === ev.value)) { source = ev.value; startList(); }
} else if (ev.type === 'pill') {
@@ -139,15 +245,23 @@
if (c && c.id !== chipNow()) { chipOf[source] = c.id; startList(); }
} else if (ev.type === 'page') {
page = ev.value === 'next' ? Math.min(pageCount() - 1, page + 1) : Math.max(0, page - 1); bump();
} else if (ev.type === 'quick') { // a card's Add to queue
const it = items()[page * perPage() + +ev.value];
if (it && it.id) actions.addToQueue(it.id);
} else if (ev.type === 'item') {
const it = items()[page * perPage + +ev.value];
if (it) actions.openVideo(it.id, it.playlistId || current().list);
const it = items()[page * perPage() + +ev.value];
if (it && current().pick) current().pick(host, it);
// a creator card: their channel, on its Membership tab when its offer can be asked for
else if (it && it.type === 'channel') showChannel(host, it, it.offerParams ? { tab: 'membership', offerParams: it.offerParams } : {});
else if (ns.ytVr.playlist.opens(it)) playlist.open(it); // a playlist: its page, in place of the list
else if (it) actions.openVideo(it.id, it.playlistId || current().list);
}
}
// While shown: start the first source the first time, and keep a page of videos ahead
// of where you are. Returns the Catalog model.
function model(frame) {
const shape = shapeNow(); // (the layout, once per frame)
const s = current();
if (!pager && s.load && !error) startList();
if (pager && !loading && !pager.done && page >= pageCount() - 2) loadNext();
@@ -157,15 +271,18 @@
title: tr(title), tabs: tabs(), tab: source,
pills: chips, pill: chips.length ? chipNow(s) : null,
note: s.note ? tr(`yt.browse.${source}Note`) : '',
items: items().slice(page * perPage, page * perPage + perPage).map(it => ns.ytVr.card(it)),
shape: shape,
items: items().slice(page * perPage(), page * perPage() + perPage()).map(it => (s.card ? s.card(it) : ns.ytVr.card(it)))
.map(c => (shape === 'square' && !c.square && !c.fit ? { ...c, fit: 'contain' } : c)),
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,
now: frame.now / 1000, // the loading indicator's clock
body: playlist.body(tr(`yt.browse.${source}`), host.style), scroll: playlist.scroll(),
};
// Redraw on any change, and ~30 times a second while the loading indicator shows.
const spinning = loading && !m.items.length;
return { model: m, key: `${ver}|${spinning ? Math.floor(frame.now / 33) : ''}` };
const spinning = m.body ? m.body.loading : loading && !m.items.length;
return { model: m, key: `${ver}|${playlist.version()}|${Math.round(playlist.scroll())}|${ns.ytVr.queue.version()}|${ns.youtubeData.thumbsVersion()}|${spinning ? Math.floor(frame.now / 33) : ''}` };
}
return { model, event };
+26 -5
View File
@@ -1,7 +1,9 @@
// YouTube items -> the shell's generic cards (styles/kit.js). An item is what the
// youtube-*.js readers return for a video or playlist: { id, title, channel, badge, views,
// age, duration, kind ('2D' | '180' | '360'), live, watched (0-100), short, type
// ('playlist'), playlistId, videoCount, thumb }. YouTube's labels for its flags live here, and
// ('playlist'), playlistId, videoCount, thumb, thumbFrom (a playlist whose thumbnail is its
// first video's, looked up: youtube-data.js playlistThumb) }. YouTube's labels for its flags
// live here, and
// their colours as colour roles (tokens.js; YouTube's own ones are custom colours in
// provider.js's theme: vr180, mix, playlist).
(function (ns) {
@@ -18,20 +20,38 @@
const tr = (key, vars) => ns.i18n.t(key, vars); // words in the interface language (strings-en.js)
const liveFlag = () => ({ text: tr('yt.card.live'), bg: 'primary', fg: 'onSurface', dot: true }); // streaming now
// The videos in YouTube's queue (Add to queue): those added from VR, plus the queue's own
// (the provider tells each frame: setQueued, [] with no queue), and a version that bumps on
// every change (redraw keys).
const queue = { added: new Set(), inList: new Set(), ver: 0 };
const queued = id => queue.added.has(id) || queue.inList.has(id);
function noteQueued(id) { if (id && !queue.added.has(id)) { queue.added.add(id); queue.ver++; } }
function setQueued(ids) {
const key = ids.join(' ');
if (key === queue.key) return;
queue.key = key; queue.inList = new Set(ids); queue.ver++;
if (ids.length) queue.added.clear(); // (the queue's own list covers them now)
}
// A card for `item`. opts.showChannel: false leaves the channel line out (on a channel's
// own page); opts.current: the item playing now (outlined, "Now playing" as its flag).
function card(item, opts = {}) {
const playlist = item.type === 'playlist';
// A creator to join (the Memberships page): their round picture, name and monthly price.
if (item.type === 'channel') return { title: item.title, thumb: item.thumb, fit: 'round', backdrop: item.banner || '', flags: [], lines: [{ text: item.price || '' }] };
const playlist = item.type === 'playlist', podcast = playlist && !!item.square; // (a podcast: its square cover)
const kind = opts.current ? { text: tr('yt.card.nowPlaying'), bg: 'tertiary', fg: 'surface' }
: playlist && /^RD/.test(item.playlistId || '') ? { text: tr('yt.card.mix'), bg: 'mix', fg: 'surface' } // mixes' ids start with RD
: podcast ? { text: tr('yt.card.podcast'), bg: 'playlist', fg: 'surface' }
: playlist ? { text: tr('yt.card.playlist'), bg: 'playlist', fg: 'surface' }
: item.short ? { text: tr('yt.card.short'), bg: 'primary', fg: 'onSurface' } : KINDS[item.kind] || KINDS['2D'];
const stats = [item.views, item.age].filter(Boolean).join(' · ');
return {
title: item.title, thumb: item.thumb, current: !!opts.current,
corner: item.duration || (playlist && item.videoCount ? tr('yt.card.videos', { n: item.videoCount }) : ''),
flags: item.live ? [kind, liveFlag()] : [kind],
title: item.title, thumb: item.thumb || (item.thumbFrom ? ns.youtubeData.playlistThumb(item.thumbFrom) : ''), square: !!item.square, current: !!opts.current,
corner: item.duration || (playlist && item.videoCount ? tr(podcast ? 'yt.card.episodes' : 'yt.card.videos', { n: item.videoCount }) : ''),
flags: [kind, ...(item.live ? [liveFlag()] : []), ...(item.membersOnly ? [{ text: tr('yt.card.membersOnly'), bg: 'chatMember', fg: 'onChatMember' }] : [])],
progress: (item.watched || 0) / 100,
// a video (not a Short, a playlist or a live stream): Add to queue on its thumbnail (a check once queued)
quick: !playlist && !item.short && !item.live && item.id ? { icon: 'add_to_queue', on: queued(item.id) } : null,
lines: [...(opts.showChannel === false ? [] : [{ text: item.channel || '', badge: item.badge || null }]),
...(stats ? [{ text: stats }] : [])],
};
@@ -39,5 +59,6 @@
ns.ytVr = ns.ytVr || {};
ns.ytVr.card = card;
ns.ytVr.queue = { noteQueued, setQueued, queued, version: () => queue.ver }; // (what's in YouTube's queue: cards' Add to queue)
ns.ytVr.liveFlag = liveFlag; // the LIVE flag, for cards made elsewhere (a channel's featured video)
})(globalThis.__streamxr = globalThis.__streamxr || {});
+222 -22
View File
@@ -1,7 +1,8 @@
// YouTube's "Channel" panel (Profile style): which channel is open, its header, the chosen
// tab and sort, the loaded videos (a pager that loads more pages as you page through), the
// current page, the channel's featured video (a "Featured" tab, first, when the channel
// has one), and the Subscribe button (unsubscribing asks in a dialog). Three tabs aren't
// has one), and the Subscribe button (unsubscribing asks in a dialog), with the notification
// bell beside it once subscribed (a round button opening a menu of YouTube's choices). Three tabs aren't
// video grids: Store (product cards, just to look at: buying leads off YouTube), Posts (a
// scrolling feed, more loading near its end) and About (a scrolling page). On a post you
// can like / dislike it and vote in its poll (signed in; shown at once, undone if YouTube
@@ -10,18 +11,26 @@
// channels YouTube lets you search) turns the tab row into a search bar: type (the keys come
// through host.typeInto), Search, and the channel's matching videos fill the grid; back
// returns to the tab you were on. Opening the panel (or
// showing it with no channel yet) shows the playing video's channel. A panel controller:
// showing it with no channel yet) shows the playing video's channel, and while it shows that
// channel it follows to the next video's (tick). A channel with memberships has a Membership tab
// (its levels, prices and perks; joining on desktop), and a members-only video brings up a
// dialog offering it (offerDialog). A panel controller:
// see panels.js. Extra calls for the controls panel:
// openChannel(channelId, subscribedHint) show a channel (a no-op if it's already shown)
// openCurrent() show the playing video's channel
// showCurrent() the channel name was clicked: fold the panel if it
// shows that channel, else open it on it
// show(id, hint) show a channel picked elsewhere (the You panel's
// Channels list; hint: { subscribed, notifications })
// and open the panel
// noteSubscribed(id, on) a subscription changed elsewhere (controls panel)
// channelId() the channel shown (or null)
// actions used: channelHeader(id) -> Promise of header (youtube-channel.js channel()),
// channelList(id, tab, sort) -> pager (Store: Promise of [products]), channelAbout(id) ->
// Promise of about | null, channelFeatured(id) -> Promise of item | null,
// setSubscribed(id, on) -> Promise of { ok, error }, openVideo(id, playlistId), state(),
// setSubscribed(id, on) -> Promise of { ok, error }, setBell(option) -> Promise of { ok, error },
// channelOffer(params) -> Promise of offer | null (youtube-channel.js offer()),
// bellMatches(label, state), openVideo(id, playlistId), state(),
// ratePost(post, what), votePoll(post, index, on) -> Promise of { ok, error },
// postComments(post, sort) -> pager, replies(comment), rateComment(comment, what).
(function (ns) {
@@ -33,11 +42,11 @@
// The tabs the panel can show (only those the channel has; About when it has an About
// page), and each one's sort choices.
const TABS = ['videos', 'shorts', 'live', 'playlists', 'shows', 'posts', 'store', 'about'].map(id => ({ id }));
const TABS = ['videos', 'shorts', 'live', 'playlists', 'podcasts', 'shows', 'posts', 'store', 'about'].map(id => ({ id }));
const VIDEO_SORTS = [{ id: 'latest' }, { id: 'popular' }, { id: 'oldest' }];
const SORTS = { videos: VIDEO_SORTS, shorts: VIDEO_SORTS, live: VIDEO_SORTS,
playlists: [{ id: 'newest' }, { id: 'lastAdded' }],
shows: [], featured: [], posts: [], store: [], about: [] }; // these have no sort choices
podcasts: [], shows: [], featured: [], posts: [], store: [], about: [], membership: [] }; // these have no sort choices
const POSTS_AHEAD = 1200; // px from the feed's end at which the next posts load
const FEATURED_TAB = { id: 'featured' };
const firstSort = t => (SORTS[t][0] || { id: '' }).id;
@@ -73,15 +82,26 @@
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 offerParams = null, offer; // its membership offer: the params to ask with, and the offer (undefined: not asked, null: none)
let member, memberAsk = null; // your membership of it (undefined: not asked, null: none), and the request for it
let gen = 0, ver = 0; // gen: ignore answers for a channel/list no longer shown
const bump = () => { ver++; };
const playlist = ns.ytVr.playlist.create(actions); // a playlist card opened in place of the list (playlist.js)
const items = () => (pager ? pager.items : []);
// The list shows as the posts feed (the Posts tab, or a membership's members-only posts).
const feedNow = () => tab === 'posts' || (tab === 'membership' && sort === 'memberPosts');
// The Membership tab shows one of your membership's lists (its videos or posts) rather than a page.
const memberList = () => tab === 'membership' && (sort === 'memberVideos' || sort === 'memberPosts');
const pageCount = () => Math.max(1, Math.ceil(items().length / perPage));
// The tabs this channel has (all of TABS until its header arrives; About when it has an
// About page), "Featured" first once its featured video is known.
// About page), "Featured" first once its featured video is known, "Membership" last when the
// channel has that tab or its membership offer can be asked for (a member gets the tab
// but no Join button, so no offer).
const tabs = () => named([...(featured ? [FEATURED_TAB] : []),
...(header && header.tabs ? TABS.filter(t => (t.id === 'about' ? !!header.aboutToken : header.tabs[t.id])) : TABS)]);
...(header && header.tabs ? TABS.filter(t => (t.id === 'about' ? !!header.aboutToken : header.tabs[t.id])) : TABS),
...(offerParams || (header && header.tabs && header.tabs.membership) ? [{ id: 'membership' }] : [])]);
// Your rating of post p and your vote in its poll: your clicks here win over the loaded ones.
const likeOf = p => (rated.has(p.id) ? rated.get(p.id) : p.likeState);
@@ -99,6 +119,7 @@
const share = t => { const n = parseFloat(t); return isFinite(n) ? n : null; };
const f = {
at: String(i), author: p.author || (header && header.title) || '', avatar: header && header.avatar, meta: p.published,
flags: p.membersOnly ? [{ text: tr('yt.card.membersOnly'), bg: 'chatMember', fg: 'onChatMember' }] : [],
text: p.text, images: p.images || [], card: p.video ? ns.ytVr.card(p.video, { showChannel: false }) : null,
poll: p.poll ? { note: p.poll.votes, enabled: p.poll.choices.some(c => c.select || c.deselect),
choices: p.poll.choices.map((c, k) => ({ text: c.text, on: k === vote,
@@ -175,6 +196,17 @@
// for its page once per channel).
function startList() {
error = ''; page = 0; loading = false; scroll = 0; opened = null;
if (tab === 'membership') {
if (!sort) sort = 'perks';
if (memberList()) { // your membership's videos or posts, from the one request
const want = sort === 'memberVideos' ? 'videos' : 'posts';
pager = oncePager(() => loadMember().then(m => (m ? m[want] : [])));
bump(); loadNext(); return;
}
pager = null; bump();
if (offerParams) loadOffer(); else loadMember();
return;
}
if (tab === 'featured' || tab === 'about') {
pager = null; bump();
if (tab === 'about' && about === undefined) {
@@ -225,18 +257,24 @@
else if (k.length === 1) { search.text += k; bump(); }
}
function openChannel(id, subscribedHint) {
// bellHint: the channel's bell from elsewhere (the Channels list's notifications), until
// the header brings its own.
function openChannel(id, subscribedHint, bellHint) {
if (!id || id === channelId) return;
if (search) { searchFocus(false); search = null; }
channelId = id; header = null; error = ''; pager = null; page = 0; loading = false; featured = null; about = undefined; scroll = 0; opened = null;
channelId = id; header = null; error = ''; pager = null; page = 0; loading = false; featured = null; about = undefined; scroll = 0; opened = null; offerParams = null; offer = undefined; offerLoading = false; member = undefined; memberAsk = null; playlist.close();
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';
// YouTube's own answer wins over the hint (the watch page's can be the previous video's).
if (h && h.subscribed != null && sub !== 'busy') sub = h.subscribed ? 'subscribed' : 'unsubscribed';
if (!bell && h) bell = bellOf(h.notifications);
if (!offerParams && h && h.offerParams) offerParams = h.offerParams; // (a channel with memberships: its Membership tab)
const lists = tabs().filter(t => t.id !== 'featured');
if (!lists.some(t => t.id === tab) && lists.length) { tab = lists[0].id; sort = firstSort(tab); }
startList();
@@ -271,8 +309,97 @@
}, err => { if (id === channelId) { sub = before; console.warn('[streamxr] subscription change failed:', err); bump(); } });
}
// ---- the membership offer (the Membership tab: levels, prices, perks; joining on desktop)
// Ask for the offer once per channel (actions.channelOffer: youtube-channel.js offer()).
let offerLoading = false;
function loadOffer() {
if (offer !== undefined || offerLoading || !offerParams) return;
const id = channelId;
offerLoading = true; bump();
const done = o => { if (id !== channelId) return; offer = o || null; offerLoading = false; bump(); };
Promise.resolve(actions.channelOffer(offerParams)).then(done,
err => { console.warn('[streamxr] membership offer unavailable:', err && err.message || err); done(null); });
}
// Ask once per channel for your membership of it (actions.channelMembership: youtube-channel.js
// membership()); the Promise of it (null: none).
function loadMember() {
if (!memberAsk) {
const id = channelId;
const done = m => { if (id === channelId) { member = m || null; bump(); } return m || null; };
memberAsk = Promise.resolve(actions.channelMembership(id)).then(done,
err => { console.warn('[streamxr] membership unavailable:', err && err.message || err); return done(null); });
}
return memberAsk;
}
// The Membership tab's pills: a member's Perks, Videos and Posts (none for the Join offer).
const MEMBER_PILLS = [{ id: 'perks' }, { id: 'memberVideos' }, { id: 'memberPosts' }];
const pillsNow = () => (tab === 'membership' ? (member ? MEMBER_PILLS : []) : SORTS[tab] || []);
// The Membership tab as the style's 'tiers' page. A member: the channel's picture and name,
// your level, "Member · 3 months", then your perks (with their emoji / loyalty badges).
// Otherwise the Join offer: "Join this channel", YouTube's lines, then each level (its
// price, a greyed "Join on desktop": paying stays on desktop, its disclaimer, its perks),
// and the footnote.
function membershipPage() {
if (!offerParams) {
if (!member) return { kind: 'tiers', tiers: [], loading: member === undefined,
emptyText: member === undefined ? tr('yt.loading') : tr('yt.channel.membershipOnDesktop') };
return { kind: 'tiers', image: member.avatar || (header && header.avatar) || '', kicker: (header && header.title) || '',
title: member.tier, intro: [[member.status, member.length].filter(Boolean).join(' · ')].filter(Boolean),
tiers: [{ title: member.perksTitle || tr('yt.channel.perks'), items: member.perks }] };
}
if (!offer) return { kind: 'tiers', tiers: [], loading: offerLoading || offer === undefined,
emptyText: offerLoading || offer === undefined ? tr('yt.loading') : tr('yt.channel.noMembership') };
return { kind: 'tiers', image: offer.image, kicker: offer.channel, title: offer.title,
intro: [offer.subtitle, offer.alert].filter(Boolean),
tiers: offer.tiers.map((t, i) => ({ title: t.title, price: t.price, note: t.disclaimer, items: t.perks,
button: { id: `join${i}`, label: tr('yt.channel.joinOnDesktop'), enabled: false } })),
footer: offer.footer };
}
// ---- 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;
// An open playlist takes its own controls; a tab (the same one too), a pill or the search
// button closes it.
if (playlist.isOpen()) {
if (ev.type === 'tab' || ev.type === 'pill' || ev.type === 'search') { playlist.close(); if (ev.type === 'tab' && ev.value === tab) return; }
else if (playlist.event(ev)) 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) {
@@ -296,7 +423,7 @@
else if (t === tab) closePost(); // the Posts tab again: back to the feed
} else if (ev.type === 'postlike' || ev.type === 'postdislike' || ev.type === 'postcomments' || ev.type === 'vote') {
const [i, k] = ev.value.split('.').map(Number);
const p = tab === 'posts' ? items()[i] : null;
const p = feedNow() ? items()[i] : null;
if (!p) return;
if (ev.type === 'postcomments') { if (!opened) openPost(i); }
else if (ev.type === 'vote') { if (p.poll && p.poll.choices[k]) vote(p, k); }
@@ -310,19 +437,23 @@
if (ev.value !== sort) { sort = ev.value; startList(); }
} else if (ev.type === 'page') {
page = ev.value === 'next' ? Math.min(pageCount() - 1, page + 1) : Math.max(0, page - 1); bump();
} else if (ev.type === 'quick') { // a card's Add to queue
const it = tab === 'store' ? null : items()[page * perPage + +ev.value];
if (it && it.id) actions.addToQueue(it.id);
} else if (ev.type === 'item') {
const it = tab === 'store' ? null : items()[page * perPage + +ev.value]; // products aren't clickable
if (it) actions.openVideo(it.id, it.playlistId);
if (ns.ytVr.playlist.opens(it)) playlist.open(it); // a playlist: its page, in place of the list
else if (it) actions.openVideo(it.id, it.playlistId);
} else if (ev.type === 'featured' && featured) actions.openVideo(featured.id);
else if (ev.type === 'post') { // a post's attached video
const p = tab === 'posts' ? items()[+ev.value] : null;
const p = feedNow() ? items()[+ev.value] : null;
if (p && p.video) actions.openVideo(p.video.id, p.video.playlistId);
} else if (ev.type === 'scroll') { scroll = Math.max(0, Math.min(host.style.maxScroll(), ev.value)); bump(); }
}
// After a draw: keep the scroll where the style clamped it.
function afterDraw() {
if (tab === 'posts' || tab === 'about') scroll = host.style.shownScroll();
if (!playlist.isOpen() && (feedNow() || tab === 'about')) scroll = host.style.shownScroll();
}
// While shown: open the playing video's channel if none is shown yet,
@@ -330,23 +461,31 @@
// once you scroll near the end). Returns the Profile model.
function model(frame) {
if (!channelId) openCurrent();
const nearEnd = tab === 'posts' ? host.style.maxScroll() - scroll < POSTS_AHEAD : page >= pageCount() - 2;
const nearEnd = feedNow() ? host.style.maxScroll() - scroll < POSTS_AHEAD : page >= pageCount() - 2;
if (opened) { if (nearEnd) opened.view.loadMore(); } // an opened post: its next comments
else if (pager && !loading && !pager.done && nearEnd) loadNext();
const h = header, f = tab === 'featured' ? featured : null;
const isLoading = loading || (!!channelId && !header && !error);
const message = error || (pager && pager.done ? tr('yt.nothingYet') : '');
const body = tab === 'about' ? aboutPage()
const body = playlist.isOpen() ? playlist.body(search ? tr('yt.search.results') : tr(`yt.channel.${tab}`), host.style)
: tab === 'about' ? aboutPage() : tab === 'membership' && !memberList() ? membershipPage()
: opened ? { kind: 'post', post: feedPost(opened.post, opened.i), back: tr('yt.channel.posts'), thread: opened.view.content(opened.post.comments) }
: tab === 'posts' ? { kind: 'feed', posts: items().map(feedPost), loading: isLoading, emptyText: message || tr('yt.channel.noPosts') }
: feedNow() ? { kind: 'feed', posts: items().map(feedPost), loading: isLoading, emptyText: message || tr('yt.channel.noPosts') }
: null;
const card = tab === 'store' ? productCard : it => ns.ytVr.card(it, { showChannel: false });
// (square pictures, podcasts' covers, show whole in the standard card)
const card = tab === 'store' ? productCard : it => {
const c = ns.ytVr.card(it, { showChannel: false });
return c.square && !c.fit ? { ...c, fit: 'contain' } : c;
};
const m = {
header: h ? { title: h.title, badge: h.badge, avatar: h.avatar, banner: h.banner,
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,
tabs: tabs(), tab, pills: search ? [] : named(SORTS[tab] || []), pill: sort,
// 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(pillsNow()), 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') }),
focused: search.focused, button: tr('yt.search'), clearable: !!search.text || !!pager } : null,
@@ -357,14 +496,14 @@
: search && pager && pager.done && !items().length ? tr('yt.noResults', { query: search.query }) : message || tr('yt.nothingYet'),
featured: f ? { thumb: f.thumb, flags: f.live ? [ns.ytVr.liveFlag()] : [], kicker: tr('yt.channel.featuredVideo'), title: f.title,
stats: [f.views, f.age].filter(Boolean).join(' · '), description: f.description } : null,
body, scroll, now: frame.now / 1000,
body, scroll: playlist.isOpen() ? playlist.scroll() : scroll, now: frame.now / 1000,
};
// Redraw on any change, while scrolling, and ~30 times a second while the loading
// indicator shows (a body loading, or the grid's first page).
const spinning = body ? body.loading || (body.thread && (body.thread.loading || body.thread.threads.some(t => t.children && t.children.loading)))
: isLoading && !m.items.length;
const cv = opened ? opened.view.version() : '';
return { model: m, key: `${ver}|${cv}|${Math.round(scroll)}|${spinning ? Math.floor(frame.now / 33) : ''}` };
return { model: m, key: `${ver}|${cv}|${playlist.version()}|${ns.ytVr.queue.version()}|${Math.round(m.scroll)}|${spinning ? Math.floor(frame.now / 33) : ''}` };
}
// A subscription change made elsewhere (the controls' Subscribe button) for channel `id`.
@@ -381,7 +520,68 @@
host.open();
}
return { model, event, afterDraw, opened: openCurrent, openChannel, openCurrent, showCurrent, noteSubscribed, channelId: () => channelId };
// A channel picked elsewhere (the You panel's Channels list): show it and open the panel.
// hint: { subscribed, notifications } from that list, tab (optional): the tab to open on
// (e.g. 'posts'; the first one it has if it lacks that).
function show(id, hint = {}) {
if (id !== channelId) openChannel(id, hint.subscribed, hint.notifications);
if (hint.offerParams) offerParams = hint.offerParams;
if (hint.offer) offer = hint.offer;
if (hint.tab) playlist.close();
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();
}
// Every frame (shown or not): follow the playing video. When another video's channel starts
// playing and the panel still shows the previous video's channel (you didn't go elsewhere
// in it, search or open a post), it shows the new one.
let playing = null; // the playing video's channel at the last tick
// A members-only video (st.membersOnly, once per video): ask for its channel's offer, then a
// dialog: "Members only: <channel>", YouTube's line about the level it needs, and See perks
// (this panel on the channel's Membership tab) or OK. Without an offer, just OK.
let offeredFor = null; // the members-only video already asked about
let waitingSince = 0, waitingFor = null; // waiting for that video's offer params: since when (ms), for which video
const OFFER_WAIT_MS = 3000;
let normalSince = 0; // since when (ms) no members-only video has been playing
function offerDialog(mo) {
Promise.resolve(mo.offerParams ? actions.channelOffer(mo.offerParams) : null).catch(() => null).then(o => {
const d = { title: tr('yt.members.title', { name: (o && o.channel) || '' }), text: (o && o.alert) || tr('yt.membersOnlyVideo') };
if (!o) return host.confirm({ ...d, confirm: tr('yt.members.ok'), cancel: null });
return Promise.resolve(host.confirm({ ...d, confirm: tr('yt.members.seePerks'), cancel: tr('yt.members.ok') })).then(yes => {
if (yes) show(mo.channelId, { tab: 'membership', offerParams: mo.offerParams, offer: o });
});
});
}
function tick(frame) {
const mo = frame && frame.st && frame.st.membersOnly;
// (Each time one is opened: a playing video that isn't one clears it. The page's data
// naming the offer can come a moment after the video: wait up to OFFER_WAIT_MS for it.)
if (!mo) { // (a normal video for a moment: the next members-only one asks again)
waitingSince = 0;
if (!normalSince) normalSince = frame.now;
if (frame.now - normalSince > 1500) offeredFor = null;
}
else normalSince = 0;
if (mo && mo.videoId && mo.videoId !== offeredFor && host.confirm) {
if (!waitingSince || waitingFor !== mo.videoId) { waitingSince = frame.now; waitingFor = mo.videoId; }
if (mo.offerParams || frame.now - waitingSince > OFFER_WAIT_MS) {
offeredFor = mo.videoId; waitingSince = 0;
offerDialog(mo);
}
}
const now = frame && frame.st && frame.st.channelId;
if (!now || now === playing) return;
const was = playing;
playing = now;
if (was && channelId === was && !search && !opened && !playlist.isOpen()) openCurrent();
}
return { model, event, menu, tick, afterDraw, opened: openCurrent, openChannel, openCurrent, showCurrent, show, noteSubscribed, channelId: () => channelId };
}
ns.ytVr = ns.ytVr || {};
+86 -5
View File
@@ -1,11 +1,15 @@
// YouTube's own buttons on the controls panel (the shell's "extras", vr-panel.js): Captions
// (left: a menu of Off and the video's caption tracks; the chosen track's lines are drawn in
// VR by the shell, captions-overlay.js) and Save (right: a scrolling menu of Watch later and
// your playlists, each ticked when the video is in it; a choice adds it or takes it out, and
// the menu stays open).
// VR by the shell, captions-overlay.js), Info cards (left, after Captions: a menu of the
// video's cards, a choice plays what it leads to; as a card's time comes, its teaser floats
// above the button), Autoplay (right, next to Play: YouTube's switch, On / Off; during its
// countdown at a video's end, the next video floats above it, clickable to play it now), 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).
//
// const ex = ns.ytVr.extras.create(); one per VR session (provider.js session())
// ex.state(st) -> { left: [captions], right: [save] } every frame (st: { ad, noVideo }: both
// ex.state(st) -> { left: [captions, cards], right: [autoplay, save], seek: jump | null } every frame (st: { ad, noVideo, paused, live }: all
// are greyed out during an ad or with no video playing; the provider's
// state); the buttons as panel-ui.js / vr-panel.js read them
// ex.extra(id, itemId) the shell's actions.extra: a button's menu opened (itemId null) or
@@ -24,6 +28,34 @@
// A track is the current one when its language and kind (auto-generated or not) match.
const same = (t, c) => !!c && t.languageCode === c.languageCode && !!t.auto === !!c.auto;
// Info cards: the playing video's (youtube-content.js cards(), from its description panel;
// re-read once a second), and the teaser YouTube's player shows now (youtube-player.js
// cardTeaser(): the cards' times are only known to the player; re-read 4 times a second).
const infoCards = ns.youtubeData.cached(() => ns.youtubeContent.cards(), 1000, []);
const teaserNow = ns.youtubeData.cached(() => youtube.cardTeaser(), 250, '');
// The teasers that aren't info cards: the player's own card list holds only a "View
// corrections" teaser (no content) on every video; its text is ignored as a teaser.
const notCards = ns.youtubeData.cached(() => (ns.youtubeData.dig(ns.youtubeData.playerResponse(), 'cards', 'cardCollectionRenderer', 'cards') || [])
.map(c => c.cardRenderer).filter(c => c && !c.content)
.map(c => ns.youtubeData.strOf(ns.youtubeData.dig(c, 'teaser', 'simpleCardTeaserRenderer', 'message'))).filter(Boolean), 1000, []);
// The card a teaser text is about: the one whose title it holds (or that holds it), else
// the only card; null if neither.
function cardFor(text, list) {
const t = text.toLowerCase();
return list.find(c => c.title && (t.includes(c.title.toLowerCase()) || c.title.toLowerCase().includes(t)))
|| (list.length === 1 ? list[0] : null);
}
// Autoplay (YouTube's own switch, re-read twice a second): true / false, or null without one.
const autoplayNow = ns.youtubeData.cached(() => youtube.autoplay(), 500, null);
// YouTube's autoplay countdown at the end of a video (re-read 4 times a second): { videoId,
// title, channel, seconds } or null.
const countdownNow = ns.youtubeData.cached(() => youtube.autoplayCountdown(), 250, null);
// Play what info card i leads to: its video, or its playlist (a link to elsewhere can't open in VR).
function openCard(i) {
const c = infoCards()[i];
if (c && (c.videoId || c.playlistId)) ns.youtubePage.openVideo(c.videoId || '', c.playlistId);
}
// Save: the video the list is for, the list ([{ id, title, saved, busy }]), whether it's
// loading, and a problem to show (as the menu's note).
let save = { videoId: null, list: null, loading: false, note: '' };
@@ -72,10 +104,53 @@
menu: { items: list.map(o => ({ id: o.id, text: o.busy ? `${o.title}\n${tr('yt.save.busy')}` : o.title, on: o.saved, disabled: o.busy })),
note: save.loading && !list.length ? tr('yt.loading') : save.note || null, scroll: true, width: 520, stay: true },
};
return { left: [cc], right: [sv] };
// Jump ahead (YouTube Premium), at the seek bar's end (where Skip ad goes during ads),
// only while YouTube's own Jump ahead pill is on screen: the Diamond, its name,
// fast-forward; greyed out while paused.
const live = !!(st && st.live), none = !!(st && st.noVideo);
const ja = live || none || !youtube.jumpAheadOffered() ? null : { id: 'jump', icon: 'fast_forward', label: tr('yt.jumpAhead'), mark: premiumMark(),
disabled: !!(st && (st.paused || st.ad)) };
// Info cards (left, after Captions; always there, greyed on a video without cards, so the
// row never shifts): a menu of the video's cards (thumbnail, title, then who, views and
// age); while YouTube's player shows a card's teaser, it floats above the button (the
// card it's about, clickable: plays it; or just its text) and the button's icon fills.
const ic = infoCards(), shown = ad ? '' : teaserNow(), tease = shown && !notCards().includes(shown) ? shown : '';
const due = tease ? cardFor(tease, ic) : null;
const cards = {
id: 'cards', icon: 'info', label: tr('yt.cards'), on: !!tease, disabled: !ic.length || ad,
teaser: tease ? { label: tr('yt.cards.teaser'),
card: { title: due ? due.title : tease, thumb: (due && due.image) || '', icon: 'info', corner: (due && due.duration) || '',
flags: [], lines: [{ text: (due && due.subtitle) || '' }], inert: true } } : null,
menu: { items: ic.map((c, i) => ({ id: String(i), disabled: !(c.videoId || c.playlistId), image: c.image || '', corner: c.duration || '',
text: `${c.title}\n${[c.subtitle, c.videoId || c.playlistId ? '' : tr('yt.cards.link')].filter(Boolean).join(' · ')}` })),
note: null, scroll: ic.length > 5, width: 560 },
};
// Autoplay (right, next to Play: balancing Info cards): YouTube's switch, On / Off under
// its icon; greyed when the page shows none.
const auto = autoplayNow();
// While YouTube counts down to the next video, the video floats above the button ("Up next
// in 5"; clickable: plays it now; turning Autoplay off cancels it).
const cd = auto && !ad ? countdownNow() : null;
const ap = { id: 'autoplay', icon: 'autoplay', label: tr(auto ? 'yt.autoplay.on' : 'yt.autoplay.off'), on: !!auto, disabled: auto == null || none,
teaser: cd ? { label: cd.seconds != null ? tr('yt.autoplay.upNextIn', { n: cd.seconds }) : tr('yt.autoplay.upNext'),
card: { title: cd.title, thumb: ns.youtubeData.thumbOf(cd.videoId), flags: [], lines: [{ text: cd.channel }], inert: true } } : null };
return { left: [cc, cards], right: [ap, sv], seek: ja };
}
function extra(id, itemId) {
if (id === 'jump') { youtube.jumpAhead(); return; }
if (id === 'autoplay') { // the countdown's card: play it now; else the switch
const cd = countdownNow();
if (itemId === 'teaser') { if (cd) ns.youtubePage.openVideo(cd.videoId); return; }
const on = !autoplayNow();
if (!on && cd) youtube.cancelAutoplay(); // (switching it off cancels the countdown too)
youtube.setAutoplay(on); autoplayNow.reset(); countdownNow.reset(); return;
}
if (id === 'cards') { // a card from the menu, or the teaser's (its card)
if (itemId === 'teaser') { const c = cardFor(teaserNow(), infoCards()); if (c) openCard(infoCards().indexOf(c)); }
else if (itemId != null) openCard(+itemId);
return;
}
if (id === 'captions' && itemId != null) {
const c = captions();
youtube.setCaptions(itemId === 'off' ? null : c.tracks[+itemId] || null);
@@ -92,5 +167,11 @@
}
ns.ytVr = ns.ytVr || {};
// YouTube Premium's mark: Material Symbols' Diamond, YouTube red when you have Premium, purple
// (an upsell, like YouTube's own) when you don't. An icon spec for the shell (symbols.js drawIcon).
const PREMIUM_RED = '#ff0033', UPSELL_PURPLE = '#a142f4';
function premiumMark() { return { name: 'diamond', color: youtube.hasPremium() ? PREMIUM_RED : UPSELL_PURPLE, filled: true }; }
ns.ytVr.extras = { create };
ns.ytVr.premiumMark = premiumMark;
})(globalThis.__streamxr = globalThis.__streamxr || {});
+12 -2
View File
@@ -17,9 +17,17 @@
// when it goes), stays (optional: it doesn't fade with the rest while you watch),
// create(host) -> controller }.
// host (given by vr-panel.js): { actions (the session's: provider + shell), style (this panel's style instance:
// maxScroll(), shownScroll() ...), perPage (cards / rows per page), open(), close(),
// isOpen() (this panel), closeMenu() (the controls panel's open menu), confirm(d) (ask
// maxScroll(), shownScroll() ...), perPage (cards / rows per page), open(), close(), reveal()
// (bring hidden panels back, e.g. for an end screen that just started),
// isOpen() (this panel), closeMenu() (the controls panel's open menu), openMenu(control, opts)
// (a floating menu on one of this panel's controls, e.g. 'action:bell'; opts.below: a
// dropdown under the control instead of above it, clear of the side toolbar; its items from the
// controller's menu(control) -> { items: [{ id, text, on, disabled, icons }], note, stay },
// a choice arriving as { type: 'menu', value: item id, menu: control }), panel(id) (another
// panel's controller), confirm(d) (ask
// in the shell's dialog: d = { title, text, confirm, cancel, danger } -> Promise of bool),
// prompt(d) (a line of text typed into the dialog's field with the keyboard: d = { title, text,
// placeholder, value, confirm, cancel } -> Promise of the text, or null if cancelled),
// typeInto(on, opts) (opts.emojis: [{ text, image, label }], the panel's own emojis for the
// keyboard's emoji pages; ask
// for the keys, or give them back (asking brings up the in-VR keyboard): while on and
@@ -39,6 +47,8 @@
// tick(frame) (optional) every frame, shown or not
// afterDraw() (optional) after the style drew the model
// opened() (optional) its toolbar item (or pill) opened the panel
// menu(control) (optional) while a menu it opened (host.openMenu) is up, every
// frame: what it lists (null closes it)
// ...anything else the controls panel calls (e.g. channel.openChannel) }
(function (ns) {
'use strict';
+159
View File
@@ -0,0 +1,159 @@
// A playlist as a page, YouTube's side: shared by the panels whose cards can be playlists
// (Explore, You, Search, Channel, Up Next's Recommended) and by Up Next's playlist tab. The
// panel shows it in place of its list as the style's 'collection' body
// (styles/collection.js): the playlist's picture, title, author, stats and description, its
// buttons, then its videos as rows. Opened from a card: Play all · Shuffle · Save, more videos
// loading as you scroll, a row plays that video in the playlist; the panel keeps it open
// while you're elsewhere, and its back button (or the panel's tab again) returns to the list.
// The playing playlist (Up Next's tab, `live`): the videos the watch page holds, the playing
// one marked and brought into view, Shuffle as an on / off toggle (the watch page's) and Save.
//
// const view = ns.ytVr.playlist.create(actions);
// actions: playlistDetails(id) -> Promise of details (youtube-content.js), browsePlaylist(id)
// -> pager of its videos, savePlaylist(id, on) -> Promise of { ok }, openVideo(id,
// playlistId, index, params), shuffleWhenReady(id), playlistShuffled() -> bool | null,
// setPlaylistShuffle(on)
// ns.ytVr.playlist.opens(item) true for a card this opens (a playlist that isn't a mix:
// mixes have no page of their own and just play)
// view.open(item, live) open a playlist card's item ({ playlistId, title, channel,
// thumb }); live: () => the playing playlist ({ items,
// currentId, localIndex, title, channel, index, total }) and
// play(item): its rows come from there instead
// view.close(), view.isOpen(), view.id()
// view.event(ev) -> true if it was this page's ('back', 'action', 'row', 'scroll')
// view.follow() bring the playing row into view at the next draw
// view.afterDraw(style) keep the scroll where the style put it
// view.body(back, style) -> the 'collection' body (back: the back button's label, or none;
// style: the panel's style, to load more near the end), or null
// view.scroll() px scrolled, or null until the first draw after follow()
// view.version() bumps on every change (for redraw keys)
(function (ns) {
'use strict';
const tr = (key, vars) => ns.i18n.t(key, vars); // words in the interface language (strings-en.js)
const AHEAD = 1200; // px from the end at which more videos load
const isMix = id => /^RD/.test(id || ''); // mixes' ids start with RD: no page of their own
// Save changes made here, by playlist id (shared by every page, so a playlist open in two
// panels shows the same state), and a counter every page's redraw key includes.
const savedNow = new Map();
let savedVer = 0;
const isSaved = d => (savedNow.has(d.id) ? savedNow.get(d.id) : !!d.saved);
const noteSaved = (id, on) => { savedNow.set(id, on); savedVer++; };
const opens = it => !!it && it.type === 'playlist' && !!it.playlistId && !isMix(it.playlistId);
function create(actions) {
let item = null, live = null, details, pager = null, loading = false, scroll = 0, saving = false, error = '';
let gen = 0, ver = 0; // gen: ignore answers for a playlist no longer open
const bump = () => { ver++; };
// Load the next page of its videos (one request at a time).
function loadMore() {
if (!pager || loading || pager.done) return;
const p = pager;
loading = true; bump();
p.next().catch(err => { if (p === pager) { error = String(err && err.message || err); console.warn('[streamxr] playlist videos unavailable:', error); } })
.finally(() => { if (p === pager) { loading = false; bump(); } });
}
function open(it, liveOpts) {
item = it; live = liveOpts || null; details = undefined; saving = false; error = '';
scroll = live ? null : 0;
const my = ++gen, id = it.playlistId;
pager = live ? null : actions.browsePlaylist(id);
if (isMix(id) || /^TLPQ/.test(id)) details = null; // (a mix or YouTube's queue: no page of its own)
else Promise.resolve(actions.playlistDetails(id)).then(d => { if (my === gen) { details = d || null; bump(); } },
err => { if (my === gen) { details = null; console.warn('[streamxr] playlist details unavailable:', err && err.message || err); bump(); } });
bump(); loadMore();
}
function close() { if (item) { item = null; live = null; pager = null; gen++; bump(); } }
// Save / remove it (shown at once, put back if YouTube refuses).
function toggleSave() {
if (!details || !details.canSave || saving) return;
const d = details, on = !isSaved(d);
noteSaved(d.id, on); saving = true; bump();
Promise.resolve(actions.savePlaylist(d.id, on)).then(res => {
if (!res || !res.ok) { noteSaved(d.id, !on); console.warn('[streamxr] saving the playlist failed:', res && res.error); }
}, err => { noteSaved(d.id, !on); console.warn('[streamxr] saving the playlist failed:', err); })
.finally(() => { saving = false; bump(); });
}
// Shuffle: from a card, play YouTube's random first video and turn the watch page's
// shuffle on once it shows; the playing playlist: flip that toggle.
function shuffle() {
const id = item.playlistId;
if (live) { const on = actions.playlistShuffled(); if (on != null) actions.setPlaylistShuffle(!on); bump(); return; }
if (!details || !details.shuffle) return;
actions.openVideo(details.shuffle.videoId, id, undefined, details.shuffle.params);
actions.shuffleWhenReady(id);
}
function event(ev) {
if (!item) return false;
const id = item.playlistId;
if (ev.type === 'back' && !live) close();
else if (ev.type === 'scroll') { scroll = Math.max(0, ev.value); bump(); }
else if (ev.type === 'action' && ev.value === 'playAll') actions.openVideo((details && details.firstVideo) || '', id);
else if (ev.type === 'action' && ev.value === 'shuffle') shuffle();
else if (ev.type === 'action' && ev.value === 'save') toggleSave();
else if (ev.type === 'row') {
const i = +ev.value;
if (live) { const v = live().items[i]; if (v) live.play(v, i); }
else { const v = pager && pager.items[i]; if (v) actions.openVideo(v.id, id, i); }
}
else return false;
return true;
}
// The rows for `videos` (the same list and playing video: the same rows, made once).
let rowsMemo = { videos: null, playing: null, n: 0, rows: [] };
function rowsOf(videos, playing) {
if (rowsMemo.videos === videos && rowsMemo.playing === playing && rowsMemo.n === videos.length) return rowsMemo.rows;
const rows = videos.map(v => ({ thumb: v.thumb, corner: v.duration || '', title: v.title, current: v.id === playing,
line: [v.channel, v.views, v.age].filter(Boolean).join(' · ') }));
rowsMemo = { videos, playing, n: videos.length, rows };
return rows;
}
// The 'collection' body: the heading from its details (from the card's item, or the
// playing playlist, until they arrive), the buttons, and its videos so far; from a card,
// more load when you're near the end.
function body(back, style) {
if (!item) return null;
const now = live ? live() : null;
if (pager && !pager.done && !loading && style && style.maxScroll && style.maxScroll() - (scroll || 0) < AHEAD) loadMore();
const d = details || {};
const playing = now ? now.currentId : ns.youtubeData.currentVideoId();
const videos = now ? now.items : pager ? pager.items : [];
const shuffled = now ? actions.playlistShuffled() : null;
const save = d.canSave ? [{ id: 'save', label: tr(isSaved(d) ? 'yt.playlist.saved' : 'yt.playlist.save'), icon: isSaved(d) ? 'playlist_add_check' : 'playlist_add', enabled: !saving }] : [];
return {
kind: 'collection', back, image: d.thumb || item.thumb || (videos[0] && videos[0].thumb) || '',
title: d.title || (now && now.title) || item.title || '',
lines: [d.author || (now && now.channel) || item.channel || '',
now && now.total ? tr(now.queue ? 'yt.recs.queue' : 'yt.recs.playlist', { index: now.index + 1, count: now.total }) : (d.stats || []).slice(0, 2).join(' · ')],
text: d.description || '',
buttons: now
? [...(shuffled != null ? [{ id: 'shuffle', label: tr('yt.playlist.shuffle'), icon: 'shuffle', kind: shuffled ? 'filled' : 'tonal' }] : []),
// YouTube's queue: Save (a menu of where to: the panel's) and Clear (the panel asks first)
...(now.queue ? [{ id: 'saveQueue', label: tr('yt.playlist.save'), icon: 'playlist_add' }, { id: 'clearQueue', label: tr('yt.queue.clear'), icon: 'clear_all' }] : save)]
: [{ id: 'playAll', label: tr('yt.playlist.playAll'), icon: 'play_arrow', kind: 'filled', enabled: !!(d.firstVideo || videos.length) },
{ id: 'shuffle', label: tr('yt.playlist.shuffle'), icon: 'shuffle', enabled: !!d.shuffle }, ...save],
rows: rowsOf(videos, playing),
focusRow: now ? (now.localIndex != null ? now.localIndex : videos.findIndex(v => v.id === playing)) : null,
loading: !now && (loading || (!!pager && !pager.done && !videos.length && !error)),
emptyText: error ? tr('yt.playlist.failed', { error }) : tr('yt.nothingYet'),
};
}
return {
open, close, isOpen: () => !!item, id: () => (item ? item.playlistId : null), event, body,
follow() { if (item) { scroll = null; bump(); } },
afterDraw(style) { if (item && scroll == null && style.shownScroll) scroll = style.shownScroll(); },
scroll: () => scroll, version: () => `${ver}.${savedVer}`,
};
}
ns.ytVr = ns.ytVr || {};
ns.ytVr.playlist = { create, opens };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+158 -18
View File
@@ -28,7 +28,10 @@
// state(): polled every frame: { title, nowPlaying (see nowPlaying below), time, duration,
// live, paused, quality: { current, currentLabel, icons, levels: [{ id, label, icons }] }, speed,
// speeds, volume, chapters, heatmap, ad, extras (its own controls panel buttons:
// vr-panel.js; used through actions.extra), ...whatever this site's panels read },
// vr-panel.js; used through actions.extra), playable (false: the site won't play this
// video, Play greys out), ...whatever this site's panels read },
// poster() -> null | [urls] (optional) while the site won't play the video: pictures to show
// on the screen in its place, best first (xr-session.js)
// ad() -> null | { ... } the ad playing now (the shell shows ads on the cinema screen)
// captions() -> [text] the caption lines to draw over the video now (optional; [] for none)
// site: { name, panels (the chevron panels, panels.js), onControl(id, panel, shell) (controls
@@ -63,6 +66,25 @@
return [QUALITY_ICONS[q] || 'high_quality', ...(fps >= 50 ? ['60fps'] : [])];
}
// YouTube Premium's mark (extras.js premiumMark: the Diamond, red with Premium, purple without).
const diamond = () => ns.ytVr.premiumMark();
// The shell's quality state from youtube.quality(): the levels (YouTube's names and icons),
// with Premium's enhanced-bitrate one ('premium', e.g. "1080p Premium") just above its plain
// twin, marked with the Diamond and greyed out without Premium; it's the current one while
// it plays.
function qualityState(q) {
const p = q.premium;
const premiumLevel = p && { id: 'premium', label: p.label.replace(' ', '\n'), icons: [...qualityIcons(p.quality, q.labels).slice(0, 1), diamond()], disabled: !p.playable };
const levels = [];
for (const id of q.levels || []) {
if (premiumLevel && id === p.quality) levels.push(premiumLevel);
levels.push({ id, label: qualityName(id, q.labels), icons: qualityIcons(id, q.labels) });
}
if (p && p.on) return { current: 'premium', currentLabel: p.label, icons: premiumLevel.icons, levels };
return { current: q.current, currentLabel: qualityName(q.current, q.labels), icons: qualityIcons(q.current, q.labels), levels };
}
// Pick the format from player data (youtube.info(): projection[], spherical,
// partialSpherical, title, keywords, video = "WxH"):
// not marked spherical by YouTube -> cinema screen
@@ -100,7 +122,8 @@
const sub = ui === 'busy' ? { look: 'busy', label: '…' }
: d.subscribed ? { look: 'secondary', label: tr('yt.subscribed') } : { look: 'primary', label: tr('yt.subscribe') };
return {
info: [{ text: stats, color: 'onSurfaceVariant' },
// (A video YouTube won't play: why, in the error colour, in place of the stats.)
info: raw.blocked ? [{ text: raw.blocked, color: 'error' }] : [{ text: stats, color: 'onSurfaceVariant' },
...(pl ? [{ text: `${stats ? ' · ' : ''}${tr('yt.playlistPosition', { index: pl.index + 1, count: pl.count })}${pl.title ? ` · ${pl.title}` : ''}`, color: 'secondary' }] : [])],
creator: { name: raw.channel || '', badge: d.badge || null, stats: d.subscribers ? tr('yt.subscribers', { n: d.subscribers }) : '' },
actions: [
@@ -109,7 +132,10 @@
{ id: 'dislike', kind: 'icon', width: 104, icon: 'thumbDown', on: !!d.disliked, hidden: !d.canLike },
],
queue: pl ? { hasPrev: pl.index > 0, hasNext: pl.index < pl.count - 1,
prev: pl.prev ? ns.ytVr.card(pl.prev) : null, next: pl.next ? ns.ytVr.card(pl.next) : null } : null,
prev: pl.prev ? ns.ytVr.card(pl.prev) : null,
// (shuffled: a card saying the next one is random, not a video)
next: pl.shuffled ? { title: tr('yt.shuffle.next'), icon: 'shuffle', thumb: '', flags: [], lines: [{ text: tr('yt.shuffle.nextLine') }], inert: true }
: pl.next ? ns.ytVr.card(pl.next) : null } : null,
};
}
@@ -118,6 +144,48 @@
// Ads: Skip ad in VR needs the optional Skip Helper extension; checked at the start and
// at every ad (it may get installed meanwhile).
const adNow = cached(() => youtube.ad(), 200, null);
// Like youtubeData.cached (re-read at most every `ms`), but also re-read at once when the
// playing video changes: a cached answer belongs to the video it was read for (else, right
// after a switch, the new video got the previous one's answers: seen 2026-10-18).
function perVideo(fn, ms, fallback) {
let at = -Infinity, video = null, val = fallback;
return () => {
const now = performance.now(), id = ns.youtubeData.currentVideoId();
if (id !== video || now - at > ms) { try { val = fn(); } catch (_) { val = fallback; } at = now; video = id; }
return val;
};
}
// Does the watch page mark the playing video as members only? Its main info box carries a
// "Members only" badge (videoPrimaryInfoRenderer badges: BADGE_STYLE_TYPE_MEMBERS_ONLY,
// seen 2026-10-18; the recommendations' badges don't count). watchData() is null while the
// page's data is still the previous video's.
const watchMembersOnly = perVideo(() => {
const D = ns.youtubeData, w = D.watchData();
const primary = w && D.findKey(D.dig(w, 'contents', 'twoColumnWatchNextResults', 'results'), 'videoPrimaryInfoRenderer');
return !!primary && /BADGE_STYLE_TYPE_MEMBERS_ONLY/.test(JSON.stringify(primary.badges || []));
}, 500, false);
// Why YouTube won't play the video ('' when it will): its playability status's reason; a
// members-only video (YouTube's "Join this channel to get access to members-only
// content ...") gets our own short line, since joining happens on desktop. Only the
// player's data for the video playing now counts: right after switching it still holds
// the previous video's for a moment, which may not even name its video, so the data last
// seen with the previous video is remembered when the video changes (stale) and ignored
// until the player has the new one.
let seenVideo = null, seenResponse = null, staleResponse = null;
const blocked = perVideo(() => {
const pr = ns.youtubeData.playerResponse() || {}, now = ns.youtubeData.currentVideoId();
if (now !== seenVideo) { staleResponse = seenResponse; seenVideo = now; }
seenResponse = pr;
if (pr === staleResponse) return '';
const vid = pr.videoDetails && pr.videoDetails.videoId;
if (vid && vid !== now) return '';
const ps = pr.playabilityStatus || {};
if (!ps.status || ps.status === 'OK') return '';
const why = ps.reason || ns.youtubeData.strOf(ns.youtubeData.findKey(ps.errorScreen, 'title')) || ps.status;
return /members?-only|join this channel/i.test(why) ? tr('yt.membersOnlyVideo') : why;
}, 500, '');
let skipHelper = false, adSeen = false;
const checkSkipHelper = () => youtube.skipHelperInstalled().then(v => { skipHelper = v; }, () => {});
checkSkipHelper();
@@ -149,7 +217,7 @@
commentsOn: ci.on, commentCount: ci.count };
}, 2000, null);
// The playlist being played, if any: { index, count, title, id, currentId, items,
// The playlist being played, if any: { index, count, title, id, currentId, items, shuffled,
// prev, next, offset, windowLength } (re-read twice a second). For a long playlist the
// player (and the page's sidebar) hold only a WINDOW of ~100-200 videos around the
// playing one, and the player's index counts from the window's start; `offset` is the
@@ -174,11 +242,22 @@
}
const BLANK_CARD = { type: 'video', id: '', title: '', channel: '', views: '', age: '', duration: '', kind: '2D', thumb: '' };
const playlist = cached(() => {
const pos = youtube.playlistPosition();
if (!pos) return null;
const raw = youtube.playlistPosition();
if (!raw) return null;
// The player's index can be one ahead for a moment (seen just after adding to the queue):
// when its video isn't the one this tab is on, the nearest copy of that one is the
// position instead.
const pos = { ids: raw.ids, index: raw.index }, onId = ns.youtubeData.currentVideoId();
if (onId && raw.ids[raw.index] !== onId) {
let best = -1;
raw.ids.forEach((id, i) => { if (id === onId && (best < 0 || Math.abs(i - raw.index) < Math.abs(best - raw.index))) best = i; });
if (best >= 0) pos.index = best;
}
const pl = ns.youtubeContent.playlist();
// (a queue that ended, cleared from VR: no list any more, though the player still holds it)
if (!pl && plMemo && /^TLPQ/.test(plMemo.id || '')) return null;
const listId = pl ? pl.id : plMemo && plMemo.id;
if (pl && !pl.infinite) loadPlaylistDetails(pl.id);
if (pl && !pl.infinite && !pl.queue) loadPlaylistDetails(pl.id); // (the queue has no page of its own)
const d = plDetails && plDetails.id === listId ? plDetails : null;
// Where the player's window starts in the whole playlist: from the page's data when it
// describes the same window (same first video), else by finding the playing video in
@@ -207,9 +286,12 @@
const count = Math.max(total, offset + pos.ids.length, d ? d.order.length : 0);
// Every position in playlist order, with that position (plIndex) for playing it.
const items = Array.from({ length: count }, (_, i) => ({ ...(card(idAt(i)) || BLANK_CARD), plIndex: i }));
return { index, count, title: plMemo.title, id: listId || '', currentId: pos.ids[pos.index], items,
return { index, count, title: plMemo.title, id: listId || '', queue: !!(pl && pl.queue), currentId: pos.ids[pos.index], items,
offset, windowLength: pos.ids.length,
prev: card(idAt(index - 1)), next: card(idAt(index + 1)) };
// With the page's shuffle on, no next video: YouTube picks it at random only when it
// gets there (its own Next preview shows the in-order one too); `shuffled` tells.
prev: card(idAt(index - 1)), shuffled: !!page.playlistShuffled(),
next: page.playlistShuffled() ? null : card(idAt(index + 1)) };
}, 500, null);
// Play the video at a position in the whole playlist: inside the player's window with the
// player itself (stays in the list), else by opening it at that position.
@@ -256,7 +338,7 @@
function setSubscribed(id, on) {
return ns.youtubeSubscriptions
? ns.youtubeSubscriptions[on ? 'subscribe' : 'unsubscribe'](id).then(res => {
if (res && res.ok) page.noteSubscription(id, on);
if (res && res.ok) { page.noteSubscription(id, on); ns.youtubeChannel.forget(id); }
return res;
})
: Promise.resolve({ ok: false, error: 'subscriptions module missing' });
@@ -308,7 +390,7 @@
previousVideo: youtube.previousVideo,
nextVideo: youtube.nextVideo,
// YouTube's own panels (sites/youtube/page/provider/*.js).
openVideo: (id, playlistId) => { if (!page.openVideo(id, playlistId)) console.warn('[streamxr] couldn’t open that video'); },
openVideo: (id, playlistId, index, params) => { if (!page.openVideo(id, playlistId, index, params)) console.warn('[streamxr] couldn’t open that video'); },
// Channel panel: any channel's header, tab lists and About page (youtube-channel.js), and
// Subscribe / Unsubscribe confirmed by YouTube (youtube-subscriptions.js).
channelHeader: id => ns.youtubeChannel.channel(id),
@@ -321,6 +403,12 @@
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.
channelOffer: params => ns.youtubeChannel.offer(params), // a channel's membership offer (levels, perks)
channelMembership: id => ns.youtubeChannel.membership(id), // your membership of a channel (level, perks, members-only videos and posts)
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(),
@@ -334,8 +422,32 @@
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),
browseSections: (name, tab) => ns.youtubeHome.feed(name, tab), // an Explore page's sections, [{ title, items, viewAll }] per page (tab: its own tab)
browseViewAll: v => ns.youtubeHome.viewAll(v), // everything a shelf's "View all" leads to
browsePlaylist: id => ns.youtubeContent.playlistVideos(id),
// A playlist opened from its card (playlist.js): its details, saving it to your library.
// Add to queue (a card's quick button): YouTube's own queue (youtube-page.js addToQueue).
addToQueue: id => { if (page.addToQueue(id)) ns.ytVr.queue.noteQueued(id); else console.warn('[streamxr] couldn’t add to the queue'); },
// The queue's Clear (the Queue box's own command) and Save: where it can go (Watch later and
// your playlists), adding all its videos to one, and a new playlist of them.
clearQueue: () => {
const C = ns.youtubeContent, id = C.queueId();
const ok = page.runCommand(C.queueClearCommand());
if (ok) { C.queueEnded(id); playlist.reset(); } // (its tab goes at once)
return ok;
},
queueSaveTargets: listId => ns.youtubeContent.queueSaveTargets(listId),
addToPlaylist: (id, ids) => ns.youtubeContent.addToPlaylist(id, ids),
createPlaylist: (title, ids) => ns.youtubeContent.createPlaylist(title, ids),
playlistDetails: id => ns.youtubeContent.playlistDetails(id),
savePlaylist: (id, on) => ns.youtubeContent.savePlaylist(id, on),
// The playing playlist's shuffle (the watch page's toggle): state, set, and on once a
// playlist opened with Shuffle shows it.
playlistShuffled: () => page.playlistShuffled(),
setPlaylistShuffle: on => page.setPlaylistShuffle(on),
shuffleWhenReady: id => page.shuffleWhenReady(id),
myChannelId: () => ns.youtubeHome.myChannelId(), // You: Your channel
signedIn: () => ns.youtubeData.signedIn(),
// Up Next and Settings.
@@ -348,24 +460,45 @@
extra: (id, itemId) => extras.extra(id, itemId),
};
// Captions and Save (extras.js): the buttons, and the caption lines the shell draws.
// The playing video's end screen tiles for Up Next's End screen tab (youtube-content.js
// endScreen(), re-read once a second): its suggested videos and playlists, and other
// channels; the video's own channel and its Subscribe tile are left out (the controls
// panel has both), and so are links to other sites. None while the player's data is still
// the previous video's. -> { videoId (the video they're for), tiles }
const NO_END = { videoId: null, tiles: [] };
const endScreen = ns.youtubeData.cached(() => {
const vd = ns.youtubeData.playerResponse().videoDetails || {}, id = ns.youtubeData.currentVideoId();
if (!id || vd.videoId !== id) return NO_END;
const own = ns.youtubeChannel.currentChannelId();
return { videoId: id, tiles: ns.youtubeContent.endScreen().filter(t => (t.type === 'video' && t.videoId) || (t.type === 'playlist' && t.playlistId)
|| (t.type === 'channel' && t.channelId && t.channelId !== own)) };
}, 1000, NO_END);
// Captions, Info cards, Autoplay, Save and Jump ahead (extras.js): the buttons, and the
// caption lines the shell draws.
const extras = ns.ytVr.extras.create();
function state() {
const l = liveNow(), pl = playlist(), details = page.details(), title = youtube.title(), channel = youtube.channel();
ns.ytVr.queue.setQueued(pl && pl.queue ? pl.items.map(it => it.id).filter(Boolean) : []); // (the cards' Add to queue checks)
const q = youtube.quality() || { current: null, levels: [] };
const st = {
title, time: l ? l.current - l.start : youtube.currentTime(), duration: l ? l.end - l.start : youtube.duration(),
live: l && liveLabel(l), paused: youtube.isPaused(),
buffered: l || ad() ? null : youtube.bufferedEnd(), // the seek bar's loaded part (not on live streams or ads)
quality: { current: q.current, currentLabel: qualityName(q.current, q.labels), icons: qualityIcons(q.current, q.labels), levels: (q.levels || []).map(id => ({ id, label: qualityName(id, q.labels), icons: qualityIcons(id, q.labels) })) },
quality: qualityState(q),
speed: youtube.speed(), speeds: youtube.speeds(), volume: youtube.volume(), chapters: chapters(), heatmap: l ? [] : heatmap(), ad: ad(),
nowPlaying: nowPlaying({ title, channel, details, playlist: pl, subscribeUi: subscribeUi() }),
nowPlaying: nowPlaying({ title, channel, details, playlist: pl, subscribeUi: subscribeUi(), blocked: watching() ? blocked() : '' }),
playable: !(watching() && blocked()), // (false: Play greys out)
// A members-only video YouTube won't play: its channel and membership offer params (the
// Channel panel offers its perks: channel.js).
membersOnly: watching() && blocked() === tr('yt.membersOnlyVideo') && watchMembersOnly()
? { videoId: ns.youtubeData.currentVideoId(), channelId: ns.youtubeChannel.currentChannelId(), offerParams: ns.youtubeChannel.videoOfferParams() } : null,
// For YouTube's panels:
channelId: ns.youtubeChannel.currentChannelId(), details, playlist: pl, description: description(),
recommendations: page.recommendations(),
recommendations: page.recommendations(), endScreen: endScreen(),
playback: { loop: youtube.loop(), autoplay: !!youtube.autoplay() }, audio: audioTracks(),
extras: extras.state({ ad: ad() }),
extras: extras.state({ ad: ad(), paused: youtube.isPaused(), live: !!l }),
liveChat: chatFor(l), // a live stream's chat, or a finished one's chat replay (live-chat.js)
};
// No video playing (VR started from the Home page...): what the panels show instead of
@@ -374,10 +507,17 @@
if (watching()) return st;
return { ...st, title: tr('yt.noVideo'), time: 0, duration: 0, paused: true, live: null, buffered: null, chapters: [], heatmap: [], ad: null, liveChat: null,
nowPlaying: { info: [{ text: tr('yt.noVideoHint'), color: 'onSurfaceVariant' }], creator: null, actions: [], queue: null },
channelId: null, details: {}, playlist: null, description: null, recommendations: [], extras: extras.state({ ad: null, noVideo: true }) };
channelId: null, details: {}, playlist: null, description: null, recommendations: [], endScreen: NO_END, extras: extras.state({ ad: null, noVideo: true }) };
}
return { actions, state, ad, captions: () => (ad() || !watching() ? [] : extras.lines()), site: { name: 'YouTube', panels: ns.ytVr.panels, onControl } };
// While YouTube won't play the video (blocked): its thumbnail on the screen, largest first
// (not every video has every size).
function poster() {
const id = watching() && blocked() ? ns.youtubeData.currentVideoId() : '';
return id ? ['maxresdefault', 'hq720', 'sddefault', 'hqdefault'].map(n => `https://i.ytimg.com/vi/${id}/${n}.jpg`) : null;
}
return { actions, state, ad, poster, captions: () => (ad() || !watching() ? [] : extras.lines()), site: { name: 'YouTube', panels: ns.ytVr.panels, onControl } };
}
ns.ytVr = ns.ytVr || {};
+12 -5
View File
@@ -24,6 +24,7 @@
let suggestions = [], suggestFor = ''; // the list, and the text it's for
let typedAt = 0, asked = ''; // when the text last changed; the text last asked about
const bump = () => { ver++; };
const playlist = ns.ytVr.playlist.create(actions); // a playlist result opened in place of the results (playlist.js)
// The text changed: suggestions are asked for once typing pauses (see `tick`); no text,
// no suggestions.
@@ -69,7 +70,7 @@
const q = text.trim();
focus(false);
if (!q) return;
query = q; error = ''; page = 0; loading = false;
query = q; error = ''; page = 0; loading = false; playlist.close();
try { pager = actions.search(q); } catch (err) { pager = null; error = String(err && err.message || err); }
bump();
loadNext();
@@ -84,9 +85,10 @@
}
function event(ev) {
if (playlist.event(ev)) return; // an open playlist's own controls
if (ev.type === 'field') focus(true);
else if (ev.type === 'clear') { // start over: no text, no results, not typing
text = ''; query = ''; pager = null; page = 0; loading = false; error = '';
text = ''; query = ''; pager = null; page = 0; loading = false; error = ''; playlist.close();
textChanged(); focus(false); bump();
}
else if (ev.type === 'submit') submit();
@@ -97,9 +99,13 @@
else if (ev.type === 'key') { if (focused) key(ev.value); }
else if (ev.type === 'page') {
page = ev.value === 'next' ? Math.min(pageCount() - 1, page + 1) : Math.max(0, page - 1); bump();
} else if (ev.type === 'quick') { // a card's Add to queue
const it = items()[page * perPage + +ev.value];
if (it && it.id) actions.addToQueue(it.id);
} else if (ev.type === 'item') {
const it = items()[page * perPage + +ev.value];
if (it) actions.openVideo(it.id, it.playlistId);
if (ns.ytVr.playlist.opens(it)) playlist.open(it); // a playlist: its page, in place of the results
else if (it) actions.openVideo(it.id, it.playlistId);
}
}
@@ -116,13 +122,14 @@
page, pageCount: pageCount(), more: !!pager && !done, loading,
emptyText: loading ? tr('yt.searching') : error || (query ? tr('yt.noResults', { query }) : tr('yt.search.hint')),
now: frame.now / 1000, // the loading indicator's clock
body: playlist.body(tr('yt.search.results'), host.style), scroll: playlist.scroll(),
};
const bar = { id: 'query', text, placeholder: tr('yt.search.placeholder'), focused, button: tr('yt.search'), clearable: !!text || !!pager,
// Only while typing, and only for what's typed now.
suggestions: focused && suggestFor === text.trim() ? suggestions : [] };
// Redraw on any change, and ~30 times a second while the loading indicator shows.
const spinning = loading && !m.items.length;
return { model: m, key: `${ver}|${spinning ? Math.floor(frame.now / 33) : ''}`, bar, open: !!query };
const spinning = m.body ? m.body.loading : loading && !m.items.length;
return { model: m, key: `${ver}|${playlist.version()}|${Math.round(playlist.scroll())}|${ns.ytVr.queue.version()}|${ns.youtubeData.thumbsVersion()}|${spinning ? Math.floor(frame.now / 33) : ''}`, bar, open: !!query };
}
return { model, event, tick };
+4 -1
View File
@@ -25,6 +25,7 @@
function create(host) {
const { actions } = host;
let tab = 'playback', audioPage = 0, vs = {}, st = {};
const scrolls = {}; // tab id -> px its rows are scrolled (each tab keeps its own)
{ const t = ns.storage.get(TAB_KEY); if (TABS.includes(t)) tab = t; }
function model(frame) {
@@ -48,13 +49,15 @@
{ label: tr('yt.settings.loop'), control: { type: 'switch', id: 'loop', on: !!p.loop }, hint: tr('yt.settings.loopHint') },
{ label: tr('yt.settings.autoplay'), control: { type: 'switch', id: 'autoplay', on: !!p.autoplay }, hint: tr('yt.settings.autoplayHint') },
] };
const m = { tabs, tab: t, content };
const m = { tabs, tab: t, content, scroll: scrolls[t] || 0 };
return { model: m, key: JSON.stringify(m) };
}
function event(ev) {
const p = st.playback || {};
if (ev.type === 'tab') { tab = ev.value; ns.storage.set(TAB_KEY, tab); }
else if (ev.type === 'scroll') scrolls[tab] = Math.max(0, Math.min(host.style.maxScroll(), ev.value)); // (the rows, dragged or by the stick)
else if (ev.type === 'text') { /* (a press on the rows: a drag may follow) */ }
else if (ev.type === 'switch' && ev.value === 'loop') actions.setLoop(!p.loop);
else if (ev.type === 'switch' && ev.value === 'autoplay') actions.setAutoplay(!p.autoplay);
else if (ev.type === 'choice' && ev.value === 'more') audioPage++;
@@ -35,6 +35,13 @@
'yt.noVideoHint': 'Pick one from Explore, You or Search',
'yt.cc.off': 'Off',
'yt.save': 'Save',
'yt.cards': 'Cards',
'yt.cards.teaser': 'Info card',
'yt.cards.link': 'A link: opens on desktop',
'yt.autoplay.on': 'On',
'yt.autoplay.off': 'Off',
'yt.autoplay.upNext': 'Up next',
'yt.autoplay.upNextIn': 'Up next in {n}',
'yt.save.signIn': 'Sign in on YouTube to save videos',
'yt.save.none': 'No playlists yet',
'yt.save.busy': 'Updating…',
@@ -46,9 +53,23 @@
'yt.card.nowPlaying': 'Now playing',
'yt.card.mix': 'Mix',
'yt.card.playlist': 'Playlist',
'yt.card.podcast': 'Podcast',
'yt.card.membersOnly': 'Members only',
'yt.membersOnlyVideo': 'Members only: join this channel on desktop to watch it.',
'yt.members.title': 'Members only: {name}',
'yt.members.seePerks': 'See perks',
'yt.members.ok': 'OK',
'yt.channel.membership': 'Membership',
'yt.channel.joinOnDesktop': 'Join on desktop',
'yt.channel.noMembership': 'Couldn’t read this channel’s memberships.',
'yt.channel.membershipOnDesktop': 'This channel’s membership is on YouTube on desktop for now.',
'yt.channel.perks': 'Perks',
'yt.channel.memberVideos': 'Videos',
'yt.channel.memberPosts': 'Posts',
'yt.card.short': 'Short',
'yt.card.live': 'LIVE',
'yt.card.videos': { one: '{n} video', other: '{n} videos' },
'yt.card.episodes': { one: '{n} episode', other: '{n} episodes' },
// The controls panel: Subscribe button, playlist position.
'yt.subscribe': 'Subscribe',
@@ -63,13 +84,31 @@
'yt.recs.title': 'Up next',
'yt.recs.empty': 'No suggestions on this page yet.',
'yt.recs.recommended': 'Recommended',
'yt.recs.endScreen': 'End screen',
'yt.recs.playlist': 'Playlist · {index} / {count}',
'yt.recs.queue': 'Queue · {index} / {count}',
'yt.queue.clear': 'Clear',
'yt.queue.clearTitle': 'Clear the queue?',
'yt.queue.clearText': 'Its videos leave the queue; the video playing now keeps playing.',
'yt.queue.newPlaylist': 'New playlist…',
'yt.queue.newTitle': 'New playlist from the queue',
'yt.queue.namePlaceholder': 'Playlist name',
'yt.queue.create': 'Create',
'yt.queue.created': 'Saved to a new private playlist',
// Search.
'yt.search.placeholder': 'Search YouTube',
'yt.search.hint': 'Click the search box and type what to look for.',
'yt.search.failed': 'Couldn’t search ({error}).',
'yt.search.resultsFor': 'Results for “{query}”',
'yt.search.results': 'Results',
'yt.playlist.playAll': 'Play all',
'yt.playlist.shuffle': 'Shuffle',
'yt.playlist.save': 'Save',
'yt.playlist.saved': 'Saved',
'yt.playlist.failed': 'Couldn’t load this playlist ({error}).',
'yt.shuffle.next': 'A random video from this playlist',
'yt.shuffle.nextLine': 'Shuffle is on',
// Browse.
'yt.browse.home': 'Home',
@@ -88,6 +127,12 @@
'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.all': 'All',
'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',
@@ -148,6 +193,7 @@
'yt.channel.shorts': 'Shorts',
'yt.channel.live': 'Live',
'yt.channel.playlists': 'Playlists',
'yt.channel.podcasts': 'Podcasts',
'yt.channel.shows': 'Shows',
'yt.channel.posts': 'Posts',
'yt.channel.store': 'Store',
@@ -25,6 +25,13 @@
'yt.noVideoHint': 'Elige uno en Explorar, Tú o Buscar',
'yt.cc.off': 'No',
'yt.save': 'Guardar',
'yt.cards': 'Tarjetas',
'yt.cards.teaser': 'Tarjeta',
'yt.cards.link': 'Un enlace: se abre en el escritorio',
'yt.autoplay.on': 'Sí',
'yt.autoplay.off': 'No',
'yt.autoplay.upNext': 'A continuación',
'yt.autoplay.upNextIn': 'A continuación en {n}',
'yt.save.signIn': 'Inicia sesión en YouTube para guardar vídeos',
'yt.save.none': 'Aún no hay listas',
'yt.save.busy': 'Actualizando…',
@@ -36,9 +43,23 @@
'yt.card.nowPlaying': 'Reproduciendo',
'yt.card.mix': 'Mix',
'yt.card.playlist': 'Lista',
'yt.card.podcast': 'Pódcast',
'yt.card.membersOnly': 'Solo miembros',
'yt.membersOnlyVideo': 'Solo para miembros: únete a este canal en el escritorio para verlo.',
'yt.members.title': 'Solo para miembros: {name}',
'yt.members.seePerks': 'Ver ventajas',
'yt.members.ok': 'Aceptar',
'yt.channel.membership': 'Membresía',
'yt.channel.joinOnDesktop': 'Únete en el escritorio',
'yt.channel.noMembership': 'No se pudieron leer las membresías de este canal.',
'yt.channel.membershipOnDesktop': 'Por ahora, la membresía de este canal está en YouTube en el escritorio.',
'yt.channel.perks': 'Ventajas',
'yt.channel.memberVideos': 'Videos',
'yt.channel.memberPosts': 'Publicaciones',
'yt.card.short': 'Short',
'yt.card.live': 'EN DIRECTO',
'yt.card.videos': { one: '{n} vídeo', other: '{n} vídeos' },
'yt.card.episodes': { one: '{n} episodio', other: '{n} episodios' },
// The controls panel.
'yt.subscribe': 'Suscribirse',
@@ -53,13 +74,31 @@
'yt.recs.title': 'A continuación',
'yt.recs.empty': 'Aún no hay sugerencias en esta página.',
'yt.recs.recommended': 'Recomendados',
'yt.recs.endScreen': 'Pantalla final',
'yt.recs.playlist': 'Lista · {index} / {count}',
'yt.recs.queue': 'Cola · {index} / {count}',
'yt.queue.clear': 'Borrar',
'yt.queue.clearTitle': '¿Borrar la cola?',
'yt.queue.clearText': 'Sus videos salen de la cola; el video que se reproduce sigue.',
'yt.queue.newPlaylist': 'Nueva lista…',
'yt.queue.newTitle': 'Nueva lista con la cola',
'yt.queue.namePlaceholder': 'Nombre de la lista',
'yt.queue.create': 'Crear',
'yt.queue.created': 'Guardada en una nueva lista privada',
// Search.
'yt.search.placeholder': 'Buscar en YouTube',
'yt.search.hint': 'Pulsa en el cuadro de búsqueda y escribe lo que buscas.',
'yt.search.failed': 'No se pudo buscar ({error}).',
'yt.search.resultsFor': 'Resultados de «{query}»',
'yt.search.results': 'Resultados',
'yt.playlist.playAll': 'Reproducir todo',
'yt.playlist.shuffle': 'Aleatorio',
'yt.playlist.save': 'Guardar',
'yt.playlist.saved': 'Guardada',
'yt.playlist.failed': 'No se pudo cargar esta lista ({error}).',
'yt.shuffle.next': 'Un video al azar de esta lista',
'yt.shuffle.nextLine': 'Aleatorio activado',
// Browse.
'yt.browse.home': 'Inicio',
@@ -78,6 +117,12 @@
'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.all': 'Todo',
'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',
@@ -138,6 +183,7 @@
'yt.channel.shorts': 'Shorts',
'yt.channel.live': 'En directo',
'yt.channel.playlists': 'Listas',
'yt.channel.podcasts': 'Pódcasts',
'yt.channel.shows': 'Programas',
'yt.channel.posts': 'Publicaciones',
'yt.channel.store': 'Tienda',
+175 -41
View File
@@ -1,23 +1,45 @@
// YouTube's "Up Next" panel (Queue style): Recommended, and in a playlist a second tab with
// the playlist's videos. A newly started playlist opens on its tab, and whenever the playing
// video changes that tab jumps to the page holding it. Endless Up Next: once you reach the
// next-to-last page of Recommended, YouTube's next batch is fetched in the background (what
// scrolling its sidebar does), so the Next arrow stays available; one request at a time,
// stopping when YouTube has no more, until the list changes (its first video changes).
// YouTube's "Up Next" panel (Queue style): Recommended; in a playlist a tab with the playlist
// as a page (playlist.js, 'live': its details, Shuffle as an on / off toggle, Save, and its
// videos as rows, the playing one brought into view); and once a video's end screen starts,
// an End screen tab with its tiles as cards (suggested videos and playlists, other channels:
// provider.js endScreen()). A newly started playlist opens on its tab, and whenever the
// playing video changes that tab brings it into view; an end screen starting switches to its
// tab and opens the panel if it's closed; the tab stays until another video starts. A playlist
// card (in Recommended or the end screen) opens that playlist as a page in the tab's place (a
// back button returns). Endless Up Next: once you reach the next-to-last page of
// Recommended, YouTube's next batch is fetched in the background (what scrolling its sidebar
// does), so the Next arrow stays available; one request at a time, stopping when YouTube has
// no more, until the list changes (its first video changes).
// A panel controller: see panels.js for the interface.
(function (ns) {
'use strict';
const tr = (key, vars) => ns.i18n.t(key, vars); // words in the interface language (strings-en.js)
const SETTLE_MS = 1500; // after a video switch, wait this long before its end screen can start
// An end screen tile as an item for a card (cards.js): a video, a playlist or a channel.
function tileItem(t) {
if (t.type === 'channel') return { type: 'channel', id: t.channelId, title: t.title, thumb: t.image };
if (t.type === 'playlist') return { type: 'playlist', playlistId: t.playlistId, id: t.videoId || '', title: t.title, channel: t.subtitle, thumb: t.image };
return { type: 'video', id: t.videoId, title: t.title, views: t.subtitle, thumb: t.image };
}
function create(host) {
const { actions } = host;
const per = host.perPage;
// tab: 'recs' | 'playlist'; each tab's page; seen: "<playlist id>|<playing video id>";
// jump: show the playing video's page at the next frame.
let tab = 'recs', recsPage = 0, plPage = 0, seen = '', jump = false;
// tab: 'recs' | 'playlist' | 'end'; Recommended's and the end screen's pages; seen:
// "<playlist id>|<playing video id>".
let tab = 'recs', recsPage = 0, endPage = 0, seen = '';
let list = null, shown = []; // the last frame's playlist, and this page's items
let ends = [], endFor = null, endOn = false, endSince = 0; // the end screen's items, the video they're for (since when), whether it has started
let moreLoading = false, moreDone = false, moreKey = '';
const opened = ns.ytVr.playlist.create(actions); // a playlist card, as a page
const playing = ns.ytVr.playlist.create(actions); // the playing playlist (its tab)
// The playing playlist for its page, and playing one of its rows: at its place in the
// playlist (keeps playing the list).
const live = () => ({ items: list ? list.items : [], queue: !!(list && list.queue), currentId: list && list.currentId, localIndex: list ? list.index : null,
index: list ? list.index : 0, total: list ? list.count : 0, title: list ? list.title : '' });
live.play = it => { if (it.plIndex >= 0) actions.playlistAt(it.plIndex); else if (it.id) actions.openVideo(it.id, list && list.id); };
function loadMore(all, page, pageCount) {
const key = all.length ? all[0].id : '';
@@ -29,57 +51,169 @@
.finally(() => { moreLoading = false; });
}
// Every frame (shown or not): follow the playlist, so a new one opens on its tab.
// Every frame (shown or not): the end screen (its tab once it starts, until another video
// starts), and follow the playlist, so a new one opens on its tab, and a new video is
// brought into view.
function tick(frame) {
const st = frame.st;
const vid = ns.youtubeData.currentVideoId();
if (vid !== endFor) { // another video: no end screen until its own starts
endFor = vid; endSince = frame.now; endOn = false; ends = []; endPage = 0;
if (tab === 'end') { tab = 'recs'; opened.close(); }
}
// Only this video's tiles, and only once it has settled (just after a switch the
// player's time can still be the previous video's).
const es = st.endScreen || {}, tiles = es.videoId === vid ? es.tiles || [] : [];
if (!endOn && tiles.length && frame.now - endSince > SETTLE_MS && st.time >= Math.min(...tiles.map(t => t.start))) {
endOn = true; ends = tiles.map(tileItem); tab = 'end'; opened.close();
if (!host.isOpen()) host.open();
if (host.reveal) host.reveal(); // (panels hidden by auto-hide come back for it)
}
list = st.playlist && st.playlist.items && st.playlist.items.length ? st.playlist : null;
if (!list) { tab = 'recs'; seen = ''; return; }
// Items are in playlist order (one per position), so the playing video's page is its index's.
const curPage = () => Math.max(0, Math.floor(list.index / per));
if (list.id !== seen.split('|')[0]) tab = 'playlist';
if (`${list.id}|${list.currentId}` !== seen) { plPage = curPage(); seen = `${list.id}|${list.currentId}`; }
if (jump) { plPage = curPage(); jump = false; }
if (!list) { if (tab === 'playlist') tab = 'recs'; seen = ''; playing.close(); return; }
if (playing.id() !== list.id) playing.open({ playlistId: list.id, title: list.title }, live);
// A new playlist opens on its tab; YouTube's queue doesn't (it starts when you add to it:
// jumping there would be jarring), its tab just appears.
if (list.id !== seen.split('|')[0] && !endOn && !list.queue) tab = 'playlist';
if (`${list.id}|${list.currentId}` !== seen) { seen = `${list.id}|${list.currentId}`; playing.follow(); }
}
function model(frame) {
const onPlaylist = tab === 'playlist' && !!list;
const all = onPlaylist ? list.items : frame.st.recommendations || [];
const pageCount = Math.max(1, Math.ceil(all.length / per));
if (onPlaylist) plPage = Math.min(plPage, pageCount - 1);
else { recsPage = Math.min(recsPage, pageCount - 1); loadMore(all, recsPage, pageCount); }
const page = onPlaylist ? plPage : recsPage;
shown = all.slice(page * per, page * per + per);
const onPlaylist = tab === 'playlist' && !!list, onEnd = tab === 'end' && endOn;
const all = onPlaylist ? [] : onEnd ? ends : frame.st.recommendations || [];
const view = onPlaylist ? playing : opened.isOpen() ? opened : null;
const pageCount = view ? 1 : Math.max(1, Math.ceil(all.length / per));
if (!view && onEnd) endPage = Math.min(endPage, pageCount - 1);
else if (!view) { recsPage = Math.min(recsPage, pageCount - 1); loadMore(all, recsPage, pageCount); }
const page = view ? 0 : onEnd ? endPage : recsPage;
shown = view ? [] : all.slice(page * per, page * per + per);
const m = {
title: tr('yt.recs.title'), page, pageCount, emptyText: tr('yt.recs.empty'), tab: onPlaylist ? 'playlist' : 'recs',
tabs: list ? [{ id: 'recs', label: tr('yt.recs.recommended') }, { id: 'playlist', label: tr('yt.recs.playlist', { index: list.index + 1, count: list.count }) }] : null,
items: shown.map(it => ns.ytVr.card(it, { current: onPlaylist && it.id === list.currentId })),
title: tr('yt.recs.title'), page, pageCount, emptyText: tr('yt.recs.empty'), tab: onPlaylist ? 'playlist' : onEnd ? 'end' : 'recs',
tabs: list || endOn ? [{ id: 'recs', label: tr('yt.recs.recommended') },
...(list ? [{ id: 'playlist', label: tr(list.queue ? 'yt.recs.queue' : 'yt.recs.playlist', { index: list.index + 1, count: list.count }) }] : []), // (YouTube's queue takes the playlist's place)
...(endOn ? [{ id: 'end', label: tr('yt.recs.endScreen') }] : [])] : null,
items: shown.map(it => ns.ytVr.card(it)),
body: view ? view.body(onPlaylist ? null : tr(onEnd ? 'yt.recs.endScreen' : 'yt.recs.recommended'), host.style) : null,
scroll: view ? view.scroll() : 0, now: frame.now / 1000,
};
return { model: m, key: JSON.stringify(m) };
// (the queue's Save or Clear filled while its menu or dialog is up; Save's menu, once
// gone, isn't asked for any more)
if (inUse === 'saveQueue' && !asking && performance.now() - menuSeenAt > 300) inUse = null;
if (m.body && inUse) m.body.buttons = m.body.buttons.map(b => (b.id === inUse ? { ...b, kind: 'filled' } : b));
const spinning = m.body && m.body.loading;
// (a playlist page's key: what changes it, not its rows: a long playlist has thousands)
const key = view ? [tab, inUse, view.version(), Math.round(view.scroll() || 0), list && list.currentId, list && list.items.length,
list && list.items.filter(it => it.views).length, onPlaylist && actions.playlistShuffled(), ns.youtubeData.thumbsVersion(),
JSON.stringify(m.tabs), spinning ? Math.floor(frame.now / 33) : ''].join('|') : JSON.stringify(m);
return { model: m, key };
}
// ---- the queue's Clear and Save (its tab's buttons)
// inUse: the button whose menu or dialogs are up ('saveQueue' | 'clearQueue'), drawn filled
// in the site's colour meanwhile; menuSeenAt: when the Save menu was last asked for (it
// closes by itself on a click elsewhere); asking: a dialog of Save's still up.
let inUse = null, menuSeenAt = 0, asking = false;
// Clear: asked first (no Undo here), then the Queue box's own Clear.
function clearQueue() {
inUse = 'clearQueue';
Promise.resolve(host.confirm({ title: tr('yt.queue.clearTitle'), text: tr('yt.queue.clearText'), confirm: tr('yt.queue.clear'), danger: true }))
.then(yes => { if (yes && !actions.clearQueue()) console.warn('[streamxr] couldn’t clear the queue'); })
.finally(() => { inUse = null; });
}
// Save: a menu (on the Save button) of "New playlist…", then Watch later and your playlists
// (ticked when one holds all of the queue already); a choice adds every queued video.
// qsave: { ids (the queue's videos it's for), list: [{ id, title, saved, busy }] | null,
// loading, note }.
let qsave = { ids: [], list: null, loading: false, note: '' };
const queueIds = () => (list && list.queue ? list.items.map(it => it.id).filter(Boolean) : []);
function openSave() {
const ids = queueIds();
qsave = { ids, list: null, loading: true, note: '' }; inUse = 'saveQueue'; menuSeenAt = performance.now();
host.openMenu('action:saveQueue', { below: true }); // (a dropdown under the button)
Promise.resolve(actions.queueSaveTargets(list && list.id)).then(l => {
if (qsave.ids !== ids) return;
qsave = { ...qsave, list: l.map(o => ({ ...o, busy: false })), loading: false, note: l.length ? '' : tr('yt.save.signIn') };
}, err => { if (qsave.ids === ids) qsave = { ...qsave, loading: false, note: tr('yt.save.failed', { error: err && err.message || err }) }; });
}
function chooseSave(id) {
const ids = qsave.ids;
if (id === 'new') { // a new playlist: its name typed in a dialog
host.closeMenu();
let name = ''; asking = true;
Promise.resolve(host.prompt({ title: tr('yt.queue.newTitle'), placeholder: tr('yt.queue.namePlaceholder'), confirm: tr('yt.queue.create') }))
.then(n => { name = n || ''; return name && actions.createPlaylist(name, ids); })
.then(res => {
if (!res) return null;
// How it went, in a dialog that only informs (OK closes it).
return host.confirm({ title: res.ok ? tr('yt.queue.created') : tr('yt.save.failed', { error: res.error || '' }),
text: res.ok ? name : '', confirm: tr('yt.members.ok'), cancel: null });
})
.finally(() => { asking = false; inUse = null; });
return;
}
const row = qsave.list && qsave.list.find(o => o.id === id);
if (!row || row.busy || row.saved) return;
row.busy = true; qsave = { ...qsave, note: '' };
Promise.resolve(actions.addToPlaylist(id, ids)).then(res => {
row.busy = false;
if (res && res.ok) row.saved = true;
else qsave = { ...qsave, note: tr('yt.save.failed', { error: res && res.error || '' }) };
qsave = { ...qsave };
});
}
// The Save menu's items, each frame while it's open (host.openMenu).
function menu(local) {
if (local !== 'action:saveQueue') return null;
menuSeenAt = performance.now();
const rows = qsave.list || [];
return {
items: [{ id: 'new', text: tr('yt.queue.newPlaylist'), icons: ['playlist_add'] },
...rows.map(o => ({ id: o.id, text: o.busy ? `${o.title}\n${tr('yt.save.busy')}` : o.title, on: o.saved, disabled: o.busy || o.saved, icons: [] }))],
note: qsave.loading && !rows.length ? tr('yt.loading') : qsave.note || null, scroll: true, width: 520, stay: true,
};
}
function afterDraw() {
if (tab === 'playlist' && list) playing.afterDraw(host.style);
else if (opened.isOpen()) opened.afterDraw(host.style);
}
function event(ev) {
const onPlaylist = tab === 'playlist' && !!list;
if (ev.type === 'tab') { tab = ev.value === 'playlist' ? 'playlist' : 'recs'; if (tab === 'playlist') jump = true; }
else if (ev.type === 'page') {
const d = ev.value === 'next' ? 1 : -1; // clamped at the next frame
if (onPlaylist) plPage = Math.max(0, plPage + d); else recsPage = Math.max(0, recsPage + d);
const onPlaylist = tab === 'playlist' && !!list, onEnd = tab === 'end' && endOn;
if (ev.type === 'tab') {
opened.close(); // (any tab, the same one too: back to its cards)
tab = ev.value === 'playlist' || ev.value === 'end' ? ev.value : 'recs';
if (tab === 'playlist') playing.follow();
return;
}
if (ev.type === 'menu' && ev.menu === 'action:saveQueue') { chooseSave(ev.value); return; }
if (onPlaylist && list.queue && ev.type === 'action' && ev.value === 'saveQueue') { openSave(); return; }
if (onPlaylist && list.queue && ev.type === 'action' && ev.value === 'clearQueue') { clearQueue(); return; }
if (onPlaylist) { playing.event(ev); return; }
if (opened.isOpen() && opened.event(ev)) return;
if (ev.type === 'page') {
const d = ev.value === 'next' ? 1 : -1; // clamped at the next frame
if (onEnd) endPage = Math.max(0, endPage + d); else recsPage = Math.max(0, recsPage + d);
} else if (ev.type === 'quick') { // a card's Add to queue
const it = shown[+ev.value];
if (it && it.id) actions.addToQueue(it.id);
} else if (ev.type === 'item') {
const it = shown[+ev.value];
if (!it) return;
if (onPlaylist) {
// A playlist video: play it at its place in the playlist (keeps playing the list).
if (it.plIndex >= 0) actions.playlistAt(it.plIndex);
else actions.openVideo(it.id, list.id);
} else {
if (it.playlistId) actions.openVideo(it.id, it.playlistId); // a playlist / mix plays from its first video
else actions.openVideo(it.id);
recsPage = 0;
if (ns.ytVr.playlist.opens(it)) { opened.open(it); return; } // a playlist: its page, in place of the cards
if (it.type === 'channel') { // another channel (the end screen): the Channel panel
const ch = host.panel && host.panel('channel');
if (ch && ch.show) ch.show(it.id, {});
return;
}
if (it.playlistId) actions.openVideo(it.id, it.playlistId); // a mix plays from its first video
else actions.openVideo(it.id);
recsPage = 0;
host.closeMenu();
}
}
return { tick, model, event };
return { tick, model, event, menu, afterDraw };
}
ns.ytVr = ns.ytVr || {};
+117 -5
View File
@@ -1,6 +1,6 @@
// A YouTube channel's pages, read without leaving the watch page: the header (name, handle,
// subscribers, avatar, banner), About, Home shelves, and the Videos / Shorts / Live /
// Playlists / Shows / Posts / Store tabs, a post's comments, liking posts and voting in
// Playlists / Podcasts / Shows / Posts / Store tabs, a post's comments, liking posts and voting in
// polls (signed in), plus search inside the channel. Everything comes
// from YouTube's own browse API (the request its channel pages make), so the video keeps
// playing. Lists are pagers (`p.next()` loads a page, see youtube-data.js); the rest are
@@ -18,13 +18,22 @@
home: 'EghmZWF0dXJlZPIGBAoCMgA=', videos: 'EgZ2aWRlb3PyBgQKAjoA', shorts: 'EgZzaG9ydHPyBgUKA5oBAA==',
live: 'EgdzdHJlYW1z8gYECgJ6AA==', shows: 'EgVzaG93c_IGBAoCYgA=', playlists: 'EglwbGF5bGlzdHPyBgoKCEIGCgIQaCIA',
posts: 'EgVwb3N0c_IGBAoCSgA=', store: 'EgVzdG9yZfIGBAoCGgA=', search: 'EgZzZWFyY2jyBgQKAloA',
podcasts: 'Eghwb2RjYXN0c_IGBQoDugEA',
};
// Tab titles as shown on YouTube -> our names (the Live tab is "Live", Posts was "Community").
const TAB_NAMES = { Home: 'home', Videos: 'videos', Shorts: 'shorts', Live: 'live', Shows: 'shows', Podcasts: 'podcasts',
Playlists: 'playlists', Posts: 'posts', Community: 'posts', Store: 'store', Search: 'search', Courses: 'courses', Releases: 'releases' };
Playlists: 'playlists', Posts: 'posts', Community: 'posts', Store: 'store', Search: 'search', Courses: 'courses', Releases: 'releases', Membership: 'membership' };
// The channel of the video playing now ('' if unknown).
const currentChannelId = () => dig(D.playerResponse(), 'videoDetails', 'channelId') || '';
// The playing video's channel: the player's data when it's for this video, else the watch
// page's owner box (a video YouTube won't play, e.g. members only, may come without
// videoDetails while the page still names its channel).
function currentChannelId() {
const vd = D.playerResponse().videoDetails || {};
if (vd.channelId && vd.videoId === D.currentVideoId()) return vd.channelId;
const owner = findKey(dig(D.watchData(), 'contents'), 'videoOwnerRenderer');
return dig(owner, 'navigationEndpoint', 'browseEndpoint', 'browseId') || vd.channelId || '';
}
// One browse request for a channel tab (or a continuation token).
const browse = (channelId, params, extra) => D.innertube('browse', { browseId: channelId, ...(params ? { params: decodeURIComponent(params) } : {}), ...extra });
@@ -84,7 +93,8 @@
thumb: bestImage(g.thumbnailRenderer) };
}
// A post (backstagePostRenderer / postRenderer): { id, text, author, published, likes,
// A post (backstagePostRenderer / postRenderer): { id, text, author, channelId (the
// author's, '' if not given), avatar, published, likes, membersOnly (true/false),
// images: [urls], video: item | null, comments (the count, "882"), commentsParams (its
// own page, for postComments), likeState ('liked' | 'disliked' | 'none': yours),
// actions: { like, unlike, dislike, undislike } (endpoints for ratePost; null signed out),
@@ -109,7 +119,9 @@
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),
membersOnly: !!findKey(p, 'sponsorsOnlyBadgeRenderer'), // (its "Members only" badge)
images, video: att.videoRenderer ? D.fromCompact(att.videoRenderer) : null,
comments: strOf(reply.text), commentsParams: dig(reply, 'navigationEndpoint', 'browseEndpoint', 'params') || '',
likeState: like.isToggled || p.voteStatus === 'LIKE' ? 'liked' : dislike.isToggled || p.voteStatus === 'DISLIKE' ? 'disliked' : 'none',
@@ -145,6 +157,9 @@
const v = o[k];
if (k === 'lockupViewModel') { const it = D.fromLockup(v); if (it) out.items.push(it); continue; }
if (k === 'videoRenderer' || k === 'compactVideoRenderer' || k === 'gridVideoRenderer') { const it = D.fromCompact(v); if (it) out.items.push(it); continue; }
// A card in a horizontal card list (the Gaming page's game shelves, signed in): the older
// video fields, its channel as bylineText and views · age as metadataText (UNVERIFIED).
if (k === 'videoCardRenderer') { const it = D.fromCompact({ ...v, shortBylineText: v.bylineText || v.shortBylineText, videoInfo: v.metadataText || v.videoInfo }); if (it) out.items.push(it); continue; }
if (k === 'shortsLockupViewModel') { const it = fromShort(v); if (it) { out.items.push(it); out.shorts.push(it); } continue; }
if (k === 'gridShowRenderer') { const it = fromShow(v); if (it) out.items.push(it); continue; }
if (k === 'backstagePostRenderer' || k === 'postRenderer') { const it = fromPost(v); if (it) out.posts.push(it); continue; }
@@ -162,6 +177,8 @@
// 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),
// offerParams (signed in, a channel with memberships: for offer(), else null),
// tabs: { videos: params, ... } (only the tabs this channel has), aboutToken }. Cached
// for 5 minutes.
const headerCache = new Map(); // id -> { at, promise }
@@ -173,6 +190,9 @@
headerCache.set(channelId, { at: performance.now(), promise });
return promise;
}
// Drop channel id's saved header (after a change it holds, e.g. Subscribe), so the next
// channel() asks again.
const forget = channelId => { headerCache.delete(channelId); };
function readHeader(r) {
const ph = dig(r, 'header', 'pageHeaderRenderer', 'content', 'pageHeaderViewModel') || {};
@@ -204,9 +224,98 @@
// 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),
// Signed in, a channel with memberships: its Join button's offer params (offer()), or null.
offerParams: dig(findKey(r.header, 'ypcGetOffersEndpoint'), 'params') || null,
};
}
// 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 };
}
// ---------------------------------------------------------------- membership offer
// offer(params) -> Promise of a channel's membership offer (YouTube's "Join" popup), or null:
// { channel, image, title ("Join this channel"), subtitle, alert (e.g. "To unlock this
// video and more, choose a level that's at least $2.99/month."), tiers: [{ title, price
// ("$2.99/month"), disclaimer, perks: [{ title, text, images: [urls] }] }], footer }.
// params: a ypcGetOffersEndpoint's (videoOfferParams(), a channel header's offerParams,
// a creator card's), sent as itemParams (seen signed in, 2026-10-18; docs/youtube.md). Only
// reads the offer: joining (the Join button's checkout) stays on desktop.
async function offer(params) {
if (!params) return null;
const r = await D.innertube('ypc/get_offers', { itemParams: params });
const o = findKey(r, 'sponsorshipsOfferRenderer');
if (!o) return null;
const h = findKey(o.header, 'sponsorshipsHeaderRenderer') || {};
const tiers = (o.tiers || []).map(t => t.sponsorshipsTierRenderer).filter(Boolean).map(t => ({
title: strOf(t.title), price: strOf(t.subtitle) || strOf(t.abovePurchaseButtonText), disclaimer: strOf(t.disclaimer),
perks: (dig(t, 'perks', 'sponsorshipsPerksRenderer', 'perks') || []).map(p => p.sponsorshipsPerkRenderer).filter(Boolean).map(p => ({
title: strOf(p.title), text: strOf(p.description),
images: (p.images || []).map(i => bestImage(i)).filter(Boolean).slice(0, 8),
})),
}));
return { channel: strOf(h.superTitle), image: bestImage(h.channelImage), title: strOf(h.title), subtitle: strOf(h.subtitle),
alert: strOf(dig(findKey(o.alert, 'sponsorshipsAlertRenderer'), 'alertMessage')), tiers, footer: strOf(o.bottomDisclaimer) };
}
// membership(id) -> Promise of what the channel's Membership tab holds for you as a member,
// or null when it has no member's view (not a member, or no such tab):
// { tier (the level's name), status ("Member"), length ("3 months"), perksTitle ("Your perks"),
// badge (your loyalty badge's url), avatar, perks: [{ title, text, images: [urls] }]
// (images: its custom emoji or loyalty badges), videos: [items] (the "Highlighted content
// for members" shelf), posts: [posts] (recent members-only posts, see fromPost) }.
// Read from the tab's sponsorshipsHubViewModel (signed in, 2026-10-18; docs/youtube.md).
async function membership(channelId) {
const params = (await channel(channelId)).tabs.membership;
if (!params) return null;
const content = tabContent(await browse(channelId, params));
const hub = findKey(content, 'sponsorshipsHubViewModel');
if (!hub) return null;
const list = findKey(hub, 'sponsorshipsPerksListViewModel') || {};
const perks = (list.perks || []).map(p => ({
title: strOf(p.title), text: strOf(p.description),
images: [...(p.emoji || []).map(e => bestImage(e)), ...(p.badges || []).map(b => bestImage(b.image))].filter(Boolean),
}));
const { items, posts } = collect(content);
return { tier: strOf(hub.tierName), status: strOf(hub.membershipType), length: strOf(hub.membershipDurationOrExpiry),
perksTitle: strOf(hub.perksTitle), badge: bestImage(hub.badgeImage), avatar: bestImage(hub.avatar), perks, videos: items,
posts: posts.map(p => ({ ...p, membersOnly: true })) }; // (all of them are)
}
// The playing video's offer params (a members-only video's Join button on its watch page), or ''.
const videoOfferParams = () => (JSON.stringify(D.watchData() || {}).match(/"ypcGetOffersEndpoint":\{"params":"([^"]+)"/) || [])[1] || '';
// 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];
@@ -301,6 +410,9 @@
// shows(id) -> pager of playlist items (a show is a playlist of episodes).
const shows = channelId => tabPager(async () => browse(channelId, await tabParams(channelId, 'shows')), c => c.items);
// podcasts(id) -> pager of the channel's podcasts (playlist items with square covers).
const podcasts = channelId => tabPager(async () => browse(channelId, await tabParams(channelId, 'podcasts')), c => c.items);
// posts(id) -> pager of posts (see fromPost).
const posts = channelId => tabPager(async () => browse(channelId, await tabParams(channelId, 'posts')), c => c.posts);
@@ -354,7 +466,7 @@
}
ns.youtubeChannel = {
currentChannelId, channel, about, home, videos, shorts, live, playlists, shows, posts, store, search,
currentChannelId, channel, forget, about, offer, membership, videoOfferParams, home, videos, shorts, live, playlists, podcasts, shows, posts, store, search,
postComments, ratePost, votePoll,
collect, fromShort, fromShow, fromPost, fromProduct, TAB_PARAMS,
tabContent, pageToken, findKeyAll, // also used by youtube-home.js
+130 -23
View File
@@ -210,48 +210,113 @@
});
}
// Info cards (the "i" teasers during the video): [{ start (s), teaser, title, subtitle,
// image, videoId?, playlistId?, url? }]
// Info cards (the "i" cards): [{ title, subtitle (who · views · age, or what it is), image,
// duration, videoId?, playlistId?, url? }], as the video's description panel lists them
// (videoDescriptionInfocardsSectionRenderer: compactInfocardRenderer's content, e.g. a
// structuredDescriptionVideoLockupRenderer). Their times aren't there (nor in the player's
// card list, which only holds a "View corrections" teaser on every video): the moment a
// card's teaser shows is read from the player itself (youtube-player.js cardTeaser()).
function cards() {
const list = dig(D.playerResponse(), 'cards', 'cardCollectionRenderer', 'cards') || [];
return list.map(c => c.cardRenderer).filter(Boolean).map(c => {
const content = c.content ? c.content[Object.keys(c.content)[0]] : {};
const sec = findKey(D.watchData() && D.watchData().engagementPanels, 'videoDescriptionInfocardsSectionRenderer');
return ((sec && sec.infocards) || []).map(c => c.compactInfocardRenderer).filter(Boolean).map(c => {
const content = c.content ? c.content[Object.keys(c.content)[0]] || {} : {};
const we = findKey(content, 'watchEndpoint') || {}, ue = findKey(content, 'urlEndpoint') || {};
const be = findKey(content, 'browseEndpoint') || {};
const playlistId = we.playlistId || (be.browseId && /^VL/.test(be.browseId) ? be.browseId.slice(2) : undefined);
return {
start: (+dig(c, 'cueRanges', 0, 'startCardActiveMs') || 0) / 1000,
teaser: strOf(dig(c, 'teaser', 'simpleCardTeaserRenderer', 'message')),
title: strOf(content.videoTitle || content.playlistTitle || content.title),
subtitle: strOf(content.channelName || content.viewCountText || content.playlistVideoCount),
image: bestImage(content.videoThumbnail || content.playlistThumbnail || content.image),
videoId: we.videoId, playlistId: we.playlistId, url: ue.url,
title: strOf(content.title),
subtitle: [strOf(content.shortBylineText).replace(/^by\s+/i, ''), strOf(content.metadataDetails)].filter(Boolean).join(' · '),
image: we.videoId ? D.thumbOf(we.videoId) : bestImage(content.thumbnail),
duration: strOf(content.lengthText), videoId: we.videoId, playlistId, url: ue.url,
};
});
}).filter(c => c.title);
}
// ---------------------------------------------------------------- playlist being played
// { id, title, channel, index, total, localIndex, infinite, items: [video items] } or null
// when not in a playlist. A long playlist's sidebar (and the player) hold only a window of
// ~100-200 videos around the playing one: `index` is the playing video's position in the
// WHOLE playlist (0-based), `localIndex` its position in that window (= items), `total`
// the playlist's length (0 if not given). `infinite`: a mix, with no fixed length.
// YouTube's queue (Add to queue) lives only in the page: its playlist panel's own data
// (a list "TLPQ…" titled "Queue"), not in the watch page's. That panel's data, or null.
const ended = new Set(); // queues cleared from VR (the page may keep their data a while)
function queuePanel() {
const p = [...document.querySelectorAll('ytd-playlist-panel-renderer')].map(e => e.data || (e.__data && e.__data.data))
.find(d => d && /^TLPQ/.test(d.playlistId || '') && !ended.has(d.playlistId) && (d.contents || []).length);
return p || null;
}
// { id, title, channel, queue (it's YouTube's queue), index, total, localIndex, infinite,
// items: [video items] } or null when not in a playlist (or the queue). A long playlist's
// sidebar (and the player) hold only a window of ~100-200 videos around the playing one:
// `index` is the playing video's position in the WHOLE playlist (0-based), `localIndex`
// its position in that window (= items), `total` the playlist's length (0 if not given).
// `infinite`: a mix, with no fixed length.
function playlist() {
const pl = dig(results(), 'playlist', 'playlist');
if (!pl) return null;
const pl = queuePanel() || dig(results(), 'playlist', 'playlist');
// (the watch page's own data keeps a queue cleared from VR until the next video: not a list any more)
if (!pl || ended.has(pl.playlistId)) return null;
return {
id: pl.playlistId, title: strOf(pl.title), channel: strOf(pl.ownerName),
index: pl.currentIndex || 0, total: +pl.totalVideos || 0,
id: pl.playlistId, title: strOf(pl.title), channel: strOf(pl.ownerName), queue: /^TLPQ/.test(pl.playlistId || ''),
index: pl.currentIndex || 0, total: +pl.totalVideos || (pl.contents || []).length,
localIndex: pl.localCurrentIndex != null ? pl.localCurrentIndex : null, infinite: !!pl.isInfinite,
items: D.collectItems(pl.contents || []),
};
}
// A playlist's own page (browse "VL<id>"), asked once a minute at most: its details and its
// videos' first page come from the same answer.
const pageCache = new Map(); // playlist id -> { at, promise }
function playlistPage(playlistId) {
const hit = pageCache.get(playlistId);
if (hit && performance.now() - hit.at < 60000) return hit.promise;
const promise = D.innertube('browse', { browseId: `VL${playlistId}` });
promise.catch(() => pageCache.delete(playlistId));
pageCache.set(playlistId, { at: performance.now(), promise });
return promise;
}
// playlistDetails(id) -> Promise of a playlist's details from its own page:
// { id, title, author, authorId, stats: ["24 videos", "1,234 views", "Updated …"],
// description, thumb (its first video's), firstVideo (Play all's), shuffle: { videoId,
// params } | null (YouTube's Shuffle play), canSave (signed in, someone else's playlist),
// saved (in your library) }. Read from the page's sidebar (playlistSidebarPrimaryInfoRenderer,
// with Save as a toggleButtonRenderer of likeEndpoints and Shuffle as a SHUFFLE button's
// watchEndpoint; seen signed in, 2026-10-18), else its header.
async function playlistDetails(playlistId) {
const r = await playlistPage(playlistId);
const pri = findKey(r.sidebar, 'playlistSidebarPrimaryInfoRenderer') || {};
const sec = findKey(r.sidebar, 'playlistSidebarSecondaryInfoRenderer') || {};
const ph = dig(r, 'header', 'pageHeaderRenderer', 'content', 'pageHeaderViewModel') || {};
const owner = dig(sec, 'videoOwner', 'videoOwnerRenderer') || {};
const buttons = dig(pri, 'menu', 'menuRenderer', 'topLevelButtons') || [];
const save = buttons.map(b => b.toggleButtonRenderer).find(t => t && findKey(t, 'likeEndpoint'));
const shuffleEp = dig(buttons.map(b => b.buttonRenderer).find(b => b && dig(b, 'icon', 'iconType') === 'SHUFFLE'), 'navigationEndpoint', 'watchEndpoint');
const firstVideo = dig(pri, 'navigationEndpoint', 'watchEndpoint', 'videoId') || dig(findKey(ph.actions, 'watchEndpoint'), 'videoId') || '';
return {
id: playlistId, title: strOf(pri.title) || strOf(dig(ph, 'title', 'dynamicTextViewModel', 'text')),
author: strOf(owner.title), authorId: dig(owner, 'navigationEndpoint', 'browseEndpoint', 'browseId') || '',
stats: (pri.stats || []).map(strOf).filter(Boolean),
description: strOf(pri.description) || strOf(dig(ph, 'description', 'descriptionPreviewViewModel', 'description')),
thumb: firstVideo ? D.thumbOf(firstVideo) : '', firstVideo,
shuffle: shuffleEp && shuffleEp.videoId ? { videoId: shuffleEp.videoId, params: shuffleEp.params || '' } : null,
canSave: !!save && D.signedIn() && !findKey(save, 'signInEndpoint'), saved: !!(save && save.isToggled),
};
}
// Save a playlist to your library (on) or remove it (YouTube's Save button: like/like and
// like/removelike with the playlist as the target) -> Promise of { ok, error }.
async function savePlaylist(playlistId, on) {
try {
await D.innertube(on ? 'like/like' : 'like/removelike', { target: { playlistId } });
pageCache.delete(playlistId);
return { ok: true };
} catch (err) { return { ok: false, error: String(err && err.message || err) }; }
}
// A playlist's videos as its own page lists them (browse "VL<id>"), with the views, age and
// badge the watch page's playlist sidebar leaves out. Pager of video items, 100 a page.
// Also your own lists: 'WL' (Watch later), 'LL' (Liked videos). Mixes ("RD...") have no
// such page; the request fails for them.
function playlistVideos(playlistId) {
return D.pager(async token => {
const r = await D.innertube('browse', token ? { continuation: token } : { browseId: `VL${playlistId}` });
const r = token ? await D.innertube('browse', { continuation: token }) : await playlistPage(playlistId);
// Later pages also repeat `contents`, so their items are read from the append action
// only. A first page's videos can follow other sections (Watch later puts its own
// controls first), so every section of the list is read.
@@ -416,6 +481,48 @@
} catch (err) { return { ok: false, error: String(err && err.message || err) }; }
}
// ---------------------------------------------------------------- the queue: clear, save
// The Queue box's own Clear command (its menu's "Clear" button: an endPlaylistCommand for
// the queue, then YouTube's "Queue cleared" toast; seen 2026-10-20), for the page to run;
// null with no queue.
function queueClearCommand() {
const q = queuePanel();
const b = q && (dig(q, 'menu', 'menuRenderer', 'topLevelButtons') || []).map(x => x.buttonRenderer)
.find(x => x && findKey(x.command, 'endPlaylistCommand'));
return b ? b.command : null;
}
// The queue's list id (or ''), and forgetting a queue once it's cleared (its tab goes at once,
// while the page and the player may still hold it until the next video).
const queueId = () => { const q = queuePanel(); return q ? q.playlistId : ''; };
const queueEnded = id => { if (id) ended.add(id); };
// queueSaveTargets(listId) -> Promise of [{ id, title, saved }]: Watch later and your
// playlists, `saved` when one already holds all of the queue (playlist/get_add_to_playlist
// asked with the queue's own list id: asked with its videos' ids, more than one, YouTube answers
// HTTP 400; seen 2026-10-20). [] signed out.
async function queueSaveTargets(listId) {
if (!D.signedIn() || !listId) return [];
const r = await D.innertube('playlist/get_add_to_playlist', { playlistId: listId, excludeWatchLater: false });
return ns.youtubeChannel.findKeyAll(r, 'playlistAddToOptionRenderer').map(o => ({
id: o.playlistId, title: strOf(o.title), saved: o.containsSelectedVideos === 'ALL',
})).filter(o => o.id);
}
// addToPlaylist(playlistId, videoIds) -> Promise of { ok, error }: every video added to the
// playlist, in order (browse/edit_playlist, one ACTION_ADD_VIDEO each).
async function addToPlaylist(playlistId, videoIds) {
try {
const r = await D.innertube('browse/edit_playlist', { playlistId, actions: videoIds.map(addedVideoId => ({ action: 'ACTION_ADD_VIDEO', addedVideoId })) });
return r && r.status === 'STATUS_SUCCEEDED' ? { ok: true } : { ok: false, error: (r && r.status) || 'no answer' };
} catch (err) { return { ok: false, error: String(err && err.message || err) }; }
}
// createPlaylist(title, videoIds) -> Promise of { ok, id, error }: a new private playlist
// with the videos (playlist/create, as YouTube's "New playlist" does; works, 0.101.1).
async function createPlaylist(title, videoIds) {
try {
const r = await D.innertube('playlist/create', { title, videoIds, privacyStatus: 'PRIVATE' });
return r && r.playlistId ? { ok: true, id: r.playlistId } : { ok: false, error: 'no playlist made' };
} catch (err) { return { ok: false, error: String(err && err.message || err) }; }
}
// ---------------------------------------------------------------- transcript
// [{ start (s), text }] for the current video, or [] if it has none. YouTube refuses
// direct transcript requests, so this opens the page's own transcript panel (the
@@ -464,7 +571,7 @@
}
ns.youtubeContent = {
metadata, description, descriptionWithoutChapters, hasTranscript, owner, chapters, heatmap, mostReplayed, endScreen, cards, playlist, playlistVideos,
metadata, description, descriptionWithoutChapters, hasTranscript, owner, chapters, heatmap, mostReplayed, endScreen, cards, playlist, playlistVideos, playlistDetails, savePlaylist, queueClearCommand, queueId, queueEnded, queueSaveTargets, addToPlaylist, createPlaylist,
comments, commentsInfo, replies, rateComment, readComments, sortTokens, transcript, search, saveTargets, setSaved, signedIn: D.signedIn,
};
})(globalThis.__streamxr = globalThis.__streamxr || {});
+63 -3
View File
@@ -300,6 +300,28 @@
// age, duration, kind ('2D'|'180'|'360'), live (streaming now), watched (0-100: percent
// you've watched, 0 if none or signed out), thumb } (+ playlistId, videoCount for playlists).
const thumbOf = id => `https://i.ytimg.com/vi/${id}/mqdefault.jpg`;
// An image URL the panels can draw ('' if not): their canvases go to WebGL, so images must
// load with CORS, which i.ytimg.com allows and i9.ytimg.com (mixes' art) refuses (checked
// 2026-10-18).
const corsImage = url => (/^https:\/\/i\.ytimg\.com\//.test(url || '') ? url : '');
// playlistThumb(id) -> the thumbnail of a playlist's first video, '' until known: the first
// call looks it up (the next request, as YouTube's "play all" picks it) and thumbsVersion()
// changes when it arrives, so a panel showing it redraws.
const playlistThumbs = new Map(); // playlist id -> url ('' while looking or if none)
let thumbsVer = 0;
function playlistThumb(id) {
if (!id) return '';
if (!playlistThumbs.has(id)) {
playlistThumbs.set(id, '');
innertube('next', { playlistId: id }).then(r => {
const v = dig(r, 'currentVideoEndpoint', 'watchEndpoint', 'videoId');
if (v) { playlistThumbs.set(id, thumbOf(v)); thumbsVer++; }
}, () => {});
}
return playlistThumbs.get(id);
}
const thumbsVersion = () => thumbsVer;
// A view count as "26M views" / "1.2K watching". Takes the visible text, and the hidden
// accessibility label as a fallback for the newer cards that show only "26M".
@@ -323,7 +345,7 @@
// 4 days ago") or short ("7.4K • 4d ago", Watch later): { views: "7.4K views", age:
// "4 days ago" }, '' for what's missing.
function statsFromInfo(info) {
const parts = strOf(info).split('•').map(s => s.trim()).filter(Boolean);
const parts = strOf(info).split(/[•·]/).map(s => s.trim()).filter(Boolean);
let views = '', age = '';
for (const p of parts) {
if (!views && (viewsText(p) || /^[\d.,]+\s*[KMB]?$/i.test(p))) views = viewsText(p) || `${p} views`;
@@ -359,12 +381,42 @@
};
}
// A creator to join (the Memberships page's "Popular creators", "Up-and-coming": a lockup of
// LOCKUP_CONTENT_TYPE_TV_NETWORK, seen 2026-10-18 signed out) -> { type: 'channel', id,
// channelId, title, thumb (its round picture), banner, price ("$4.99" a month) }, or null
// without a channel id. The id isn't a field: it's in the card's links, or base64-encoded in
// the "surlp" parameter of the membership page it opens.
function fromCreator(r) {
const s = JSON.stringify(r);
let m = /"(UC[\w-]{22})"/.exec(s) || /\/channel\/(UC[\w-]{22})/.exec(s);
// Else inside an encoded parameter: the "surlp" of the membership page (signed out), or any
// other base64 value the card's commands carry (signed in, its tap opens the membership
// offer instead).
const decoded = v => {
try {
for (let i = 0; i < 4 && /%/.test(v); i++) v = decodeURIComponent(v);
return atob(v.replace(/-/g, '+').replace(/_/g, '/'));
} catch (_) { return ''; } // (not base64)
};
const candidates = [...s.matchAll(/surlp(?:%3D|=)([^"&\\]+)/g), ...s.matchAll(/"(?:params|[a-zA-Z]*Params)":"([A-Za-z0-9%_\-+/=]{16,})"/g)].map(x => x[1]);
for (const v of candidates) { if (m) break; m = /(UC[\w-]{22})/.exec(decoded(v)); }
if (!m) return null;
const md = r.metadata && r.metadata.lockupMetadataViewModel || {};
const rows = dig(md, 'metadata', 'contentMetadataViewModel', 'metadataRows') || [];
const price = rows.flatMap(row => row.metadataParts || []).map(p => strOf(p.text)).find(Boolean) || '';
// (Signed in, its tap asks for the membership offer: those params, for youtubeChannel.offer.)
const offerParams = dig(findKey(r.rendererContext, 'ypcGetOffersEndpoint'), 'params') || null;
return { type: 'channel', id: m[1], channelId: m[1], title: strOf(md.title), price, offerParams,
thumb: bestImage(findKey(md.image, 'avatarViewModel')), banner: bestImage(r.contentImage) };
}
// Newer shape: lockupViewModel (Up next, feeds, channel tabs). Videos (also SHORT, MOVIE)
// and playlists (also SHOW, PODCAST, ALBUM, and STATION = mixes); content types as listed
// by YouTube.js. Newer cards show just "26M" / "1y ago"; the accessibility label has the
// long form.
function fromLockup(r) {
if (!r || !r.contentId) return null;
if (r.contentType === 'LOCKUP_CONTENT_TYPE_TV_NETWORK') return fromCreator(r);
const isPlaylist = /PLAYLIST|SHOW|PODCAST|ALBUM|STATION/.test(r.contentType || '');
if (r.contentType && !isPlaylist && !/VIDEO|SHORT|MOVIE/.test(r.contentType)) return null;
const md = r.metadata && r.metadata.lockupMetadataViewModel || {};
@@ -389,6 +441,8 @@
if (!channelId) channelId = dig(findKey(md.image, 'browseEndpoint'), 'browseId') || '';
const title = strOf(md.title);
const firstVideo = isPlaylist ? (dig(findKey(r, 'watchEndpoint'), 'videoId') || '') : r.contentId;
// A podcast's own art is square (studio_square_thumbnail on i.ytimg.com, seen 2026-10-18).
const podcast = /PODCAST/.test(r.contentType || ''), squareArt = podcast ? corsImage(bestImage(r.contentImage)) : '';
return {
type: isPlaylist ? 'playlist' : 'video',
id: isPlaylist ? firstVideo : r.contentId,
@@ -397,9 +451,15 @@
title, channel, channelId, badge, views, age,
duration: durM ? durM[1] : '',
kind: badgeKind(md) || kindOf(title), live,
// For channel members only (a "Members only" badge: badgeStyle BADGE_MEMBERS_ONLY).
membersOnly: /BADGE_MEMBERS_ONLY|BADGE_STYLE_TYPE_MEMBERS_ONLY/.test(JSON.stringify(md)),
// Percent of it you've watched (signed in; the thumbnail's progress bar overlay).
watched: isPlaylist ? 0 : +dig(findKey(r.contentImage, 'thumbnailOverlayProgressBarViewModel'), 'startPercent') || 0,
thumb: firstVideo ? thumbOf(firstVideo) : bestImage(r.contentImage),
thumb: squareArt || (firstVideo ? thumbOf(firstVideo) : corsImage(bestImage(r.contentImage))),
square: !!squareArt, // its picture is square (a podcast's cover)
// A playlist whose own art can't be drawn (a mix's, on i9.ytimg.com) and that names no
// first video: its first video's thumbnail is looked up when shown (playlistThumb).
thumbFrom: isPlaylist && !firstVideo && !corsImage(bestImage(r.contentImage)) ? r.contentId : undefined,
};
}
@@ -426,6 +486,6 @@
compactNumber, timeAgo, longAgo, parseClock, VIEWS_RE, AGO_RE, kindOf, badgeKind, channelBadge, badgeFromData,
onNavigate, watchData, playerResponse, signedIn,
innertube, sidAuth, runEndpoint, continuationOf, nextPageToken, pager,
fromCompact, fromLockup, collectItems, thumbOf, AD_KEY_RE,
fromCompact, fromLockup, collectItems, thumbOf, playlistThumb, thumbsVersion, AD_KEY_RE,
};
})(globalThis.__streamxr = globalThis.__streamxr || {});
+62 -9
View File
@@ -50,19 +50,61 @@
return dig(findKey(c, 'richGridRenderer'), 'contents') || dig(findKey(c, 'sectionListRenderer'), 'contents') || [];
}
// A feed page as sections: [{ title, items, posts }]. Shelves ("Shorts", "Top news",
// "Live Now" ...) become their own sections; loose grid items in a row between them are
// gathered into one untitled section ('').
// Where a shelf's "View all" leads (seen 2026-10-18, signed out): { playlistId } when it's a
// playlist (Hype's shelves: the menu's browse to "VL<playlist id>"), { browseId, params }
// when it's a page of its own (Live's shelves: the shelf's `endpoint`, a channel's live
// tab; a game shelf's header: UNVERIFIED, presumably the game's own page), or null.
function viewAllOf(shelf) {
const be = dig(shelf.endpoint, 'browseEndpoint') || findKey(shelf.showMoreButton, 'browseEndpoint')
|| findKey(shelf.menu, 'browseEndpoint') || findKey(dig(shelf, 'header', 'richListHeaderRenderer'), 'browseEndpoint');
if (!be || !be.browseId) return null;
if (/^VL/.test(be.browseId)) return { playlistId: be.browseId.slice(2) };
return { browseId: be.browseId, params: be.params || '' };
}
// The next page's token of a feed page: a channel list's (C.pageToken), else a "load more"
// item at the end of the page's own section list (e.g. a long Explore page).
function pageToken(r) {
const t = C.pageToken(r);
if (t) return t;
const ents = entriesOf(r), last = ents[ents.length - 1];
return last && last.continuationItemRenderer ? D.continuationOf(last) : null;
}
// Every shelf inside one entry of a page, in order (an entry can hold several: the
// Memberships page puts "Popular creators", "Trending content", "Up-and-coming" ... in one
// item section), not looking inside a shelf once found. The newer horizontalShelfViewModel
// has its title in header.sectionHeaderViewModel; a horizontalCardListRenderer (the Gaming
// page's game shelves, signed in: its cards are videoCardRenderers, or games, left out) in
// header.richListHeaderRenderer.
const SHELF_KEYS = ['richShelfRenderer', 'shelfRenderer', 'reelShelfRenderer', 'horizontalShelfViewModel', 'horizontalCardListRenderer'];
function shelvesIn(node, out = [], depth = 0) {
if (!node || typeof node !== 'object' || depth > 20) return out;
if (Array.isArray(node)) { for (const x of node) shelvesIn(x, out, depth + 1); return out; }
for (const k of Object.keys(node)) {
if (SHELF_KEYS.includes(k) && node[k] && typeof node[k] === 'object') out.push(node[k]);
else shelvesIn(node[k], out, depth + 1);
}
return out;
}
// A feed page as sections: [{ title, items, posts, viewAll }]. Shelves ("Shorts", "Top news",
// "Live Now" ...) become their own sections (viewAll: viewAllOf); loose grid items in a row
// between them are gathered into one untitled section ('').
function sectionsOf(r) {
const out = [];
let loose = null;
for (const e of entriesOf(r)) {
if (e.continuationItemRenderer) continue;
const shelf = findKey(e, 'richShelfRenderer') || findKey(e, 'shelfRenderer') || findKey(e, 'reelShelfRenderer');
if (shelf) {
const shelves = shelvesIn(e);
if (shelves.length) {
loose = null;
const got = C.collect(shelf.contents || shelf.content || shelf.items || shelf);
if (got.items.length || got.posts.length) out.push({ title: strOf(shelf.title), items: got.items, posts: got.posts });
for (const shelf of shelves) {
const got = C.collect(shelf.contents || shelf.content || shelf.items || shelf);
const title = strOf(shelf.title) || strOf(dig(shelf, 'header', 'sectionHeaderViewModel', 'headline'))
|| strOf(dig(shelf, 'header', 'richListHeaderRenderer', 'title'));
if (got.items.length || got.posts.length) out.push({ title, items: got.items, posts: got.posts, viewAll: viewAllOf(shelf) });
}
continue;
}
const got = C.collect(e);
@@ -141,7 +183,7 @@
const msg = findKey(r.contents, 'backgroundPromoRenderer') || findKey(r.contents, 'messageRenderer');
p.message = msg ? strOf(msg.title) || strOf(msg.text) : '';
}
return { items: sectionsOf(r), token: C.pageToken(r) };
return { items: sectionsOf(r), token: pageToken(r) };
}, null);
p.tabs = []; p.title = f.title; p.message = '';
return p;
@@ -161,6 +203,17 @@
return p;
}
// viewAll(v) -> pager over everything a shelf's "View all" leads to (v: a section's
// viewAll): a playlist's videos (youtube-content.js playlistVideos), or a page's videos,
// its sections run together, page by page.
function viewAll(v) {
if (v.playlistId) return ns.youtubeContent.playlistVideos(v.playlistId);
return D.pager(async token => {
const r = token ? await more(token) : await D.innertube('browse', { browseId: v.browseId, ...(v.params ? { params: decodeURIComponent(v.params) } : {}) });
return { items: sectionsOf(r).flatMap(s => s.items), token: pageToken(r) };
}, null);
}
// ---------------------------------------------------------------- left menu
// guide() -> Promise of [{ title, section, browseId, params, url }] - the left menu as
// YouTube sends it now (Home, Shorts, Subscriptions, You, History, Explore: Music, Live,
@@ -221,5 +274,5 @@
}
}
ns.youtubeHome = { FEEDS, home, feed, feedVideos, guide, myChannelId, suggestions, sectionsOf, chipsOf };
ns.youtubeHome = { FEEDS, home, feed, feedVideos, viewAll, guide, myChannelId, suggestions, sectionsOf, chipsOf };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+75 -9
View File
@@ -30,6 +30,7 @@
if (/[?&]list=/.test(a.getAttribute('href'))) continue; // playlist card
const card = a.closest(CARD) || a.parentElement;
if (!card || card.closest('ytd-ad-slot-renderer, ytd-in-feed-ad-layout-renderer')) continue; // skip ads
if (card.closest('ytd-engagement-panel-section-list-renderer')) continue; // a side panel's (e.g. the description's info cards), not Up next
const title = textOf(card.querySelector('#video-title, h3 [title], h3, .yt-lockup-metadata-view-model__title, a#video-title-link'))
|| a.getAttribute('title') || textOf(a);
if (!title) continue;
@@ -147,16 +148,17 @@
// (with no video id, e.g. a channel's show, its first video is looked up first: opened
// without one, YouTube played a "not available on this device" placeholder instead).
// index: the video's 0-based position in the playlist (tells YouTube which copy when a
// video is listed twice, and where to load the playlist window from).
// video is listed twice, and where to load the playlist window from). params: the watch
// request's params YouTube's own button sent (e.g. a playlist's Shuffle play: its order).
// Returns true if it's opening (asynchronously when the first video had to be looked up).
function openVideo(id, playlistId, index) {
function openVideo(id, playlistId, index, params) {
if (!id && !playlistId) return false;
if (!id) {
const go = first => { if (!openNow(first || '', playlistId)) console.warn('[streamxr] could not open playlist', playlistId); };
firstVideoOf(playlistId).then(go, err => { console.warn('[streamxr] playlist lookup failed:', err); go(''); });
return true;
}
return openNow(id, playlistId, index);
return openNow(id, playlistId, index, params);
}
// The first video of a playlist, as YouTube's own "play all" picks it (the `next` request's
@@ -167,12 +169,12 @@
}
// openVideo's navigation itself (id may be '' with a playlist: YouTube picks the video).
function openNow(id, playlistId, index) {
function openNow(id, playlistId, index, params) {
const sel = REC_ROOTS.split(',').map(s => `${s.trim()} a[href*="v=${id}"]`).join(', ');
const a = id && !playlistId && document.querySelector(sel);
const a = id && !playlistId && !params && document.querySelector(sel);
const at = playlistId && index >= 0;
const url = `/watch?${[id && `v=${id}`, playlistId && `list=${playlistId}`, at && `index=${index + 1}`].filter(Boolean).join('&')}`;
const endpoint = { ...(id ? { videoId: id } : {}), ...(playlistId ? { playlistId } : {}), ...(at ? { index } : {}) };
const endpoint = { ...(id ? { videoId: id } : {}), ...(playlistId ? { playlistId } : {}), ...(at ? { index } : {}), ...(params ? { params } : {}) };
if (a) a.click();
else if (!navigate(url, endpoint)) return false;
recommendations.reset(); details.reset();
@@ -180,12 +182,15 @@
}
// In-page navigation through YouTube's app (no page reload). Returns false if unavailable.
// The event is sent from the page manager (inside the app), not the app element itself:
// sent from ytd-app it's ignored on some pages (a channel page, checked 2026-10-17), while
// from ytd-page-manager it works there and from the watch page alike.
function navigate(url, watchEndpoint) {
const app = document.querySelector('ytd-app');
if (!app) return false;
const from = document.querySelector('ytd-page-manager') || document.querySelector('ytd-app');
if (!from) return false;
const endpoint = { commandMetadata: { webCommandMetadata: { url, webPageType: 'WEB_PAGE_TYPE_WATCH', rootVe: 3832 } }, watchEndpoint };
try {
app.dispatchEvent(new CustomEvent('yt-navigate', { bubbles: true, composed: true, detail: { endpoint } }));
from.dispatchEvent(new CustomEvent('yt-navigate', { bubbles: true, composed: true, detail: { endpoint } }));
return true;
} catch (_) { return false; }
}
@@ -302,8 +307,69 @@
});
}
// ---------------------------------------------------------------- the queue
// Add a video to YouTube's queue the way its own card menu does ("Add to queue"): the same
// command (an addToPlaylistCommand of list type QUEUE; YouTube's page creates the queue the
// first time, a playlist "TLPQ…" that plays after the current video), run by the page's own
// command handler (ytd-app handleCommandWithCommandHandler; seen working 2026-10-19).
// Returns true if the page took it.
function addToQueue(videoId) {
if (!videoId) return false;
const watch = { commandMetadata: { webCommandMetadata: { url: `/watch?v=${videoId}`, webPageType: 'WEB_PAGE_TYPE_WATCH', rootVe: 3832 } }, watchEndpoint: { videoId } };
return runCommand({ signalServiceEndpoint: { signal: 'CLIENT_SIGNAL', actions: [{ addToPlaylistCommand: {
openMiniplayer: false, videoId, videoIds: [videoId], listType: 'PLAYLIST_EDIT_LIST_TYPE_QUEUE',
onCreateListCommand: { commandMetadata: { webCommandMetadata: { sendPost: true, apiUrl: '/youtubei/v1/playlist/create' } },
createPlaylistServiceEndpoint: { videoIds: [videoId], params: 'CAQ%3D' } },
videoCommand: watch } }] } });
}
// Run one of YouTube's own commands (e.g. its Queue box's Clear) with the page's command
// handler, as clicking its button would. Returns true if the page took it.
function runCommand(cmd) {
const app = document.querySelector('ytd-app');
if (!cmd || !app || typeof app.handleCommandWithCommandHandler !== 'function') return false;
try { app.handleCommandWithCommandHandler(cmd); return true; }
catch (err) { console.warn('[streamxr] YouTube\'s command failed:', err); return false; }
}
// ---------------------------------------------------------------- playlist shuffle
// The watch page's playlist panel has a Shuffle toggle (YouTube's own button, labelled
// "Shuffle playlist", aria-pressed when on). Opening a playlist with its Shuffle play params
// only picks a random first video; the toggle shuffles what plays next (works, checked in
// the headset with 0.96.1; docs/youtube.md).
function shuffleButton() {
return [...document.querySelectorAll('ytd-playlist-panel-renderer button, ytd-playlist-panel-renderer [role="button"]')]
.find(b => /shuffle/i.test(b.getAttribute('aria-label') || '') && b.getClientRects().length) || null;
}
// The playing playlist's shuffle: true / false, or null when the page shows no toggle.
function playlistShuffled() {
const b = shuffleButton();
return b ? b.getAttribute('aria-pressed') === 'true' : null;
}
// Turn it on or off by clicking the toggle; true if it was found.
function setPlaylistShuffle(on) {
const b = shuffleButton();
if (!b) return false;
if ((b.getAttribute('aria-pressed') === 'true') !== !!on) b.click();
return true;
}
// Once the watch page plays playlist `id` and shows its toggle (within `ms`), turn shuffle
// on: for a playlist just opened with Shuffle. Resolves true if it did.
function shuffleWhenReady(id, ms = 10000) {
const until = performance.now() + ms;
return new Promise(resolve => {
const tryNow = () => {
const list = D.dig(D.watchData(), 'contents', 'twoColumnWatchNextResults', 'playlist', 'playlist', 'playlistId');
if (list === id && setPlaylistShuffle(true)) { resolve(true); return; }
if (performance.now() > until) { console.warn('[streamxr] no shuffle toggle found for playlist', id); resolve(false); return; }
setTimeout(tryNow, 250);
};
setTimeout(tryNow, 500);
});
}
ns.youtubePage = {
recommendations, upNextPlaylists, loadMoreRecommendations, openVideo, navigate,
playlistShuffled, setPlaylistShuffle, shuffleWhenReady, addToQueue, runCommand,
details, subscribe, noteSubscription, like, dislike, logo,
currentVideoId: D.currentVideoId, compactNumber, timeAgo,
};
+98 -12
View File
@@ -55,30 +55,83 @@
// Video quality. YouTube ids: 'hd2160', 'hd1440', 'hd1080', 'hd720', 'large' (480p),
// 'medium' (360p), 'small', 'tiny', 'auto'. labels: YouTube's own name for each id where
// the player gives one (getAvailableQualityData's qualityLabel, e.g. "1080p60" on a 60 fps
// video; Premium's extra "1080p Premium" entry is skipped in favour of the plain one).
// video; Premium's extra entry is kept apart as `premium`).
// premium: YouTube Premium's enhanced-bitrate quality, or null when the video has none:
// { quality (its id, 'hd1080'), label ("1080p Premium"), formatId, playable (false: you
// don't have Premium), on (playing now) }. Seen signed in with Premium (2026-10-17): an
// entry with paygatedQualityDetails and its own formatId ("721") beside the plain
// 1080p's ("399"); getPlaybackQualityLabel() names the one playing.
// formats: the plain entries' formatIds by id, where the player gives one.
function quality() {
try {
const p = player();
const levels = (p && p.getAvailableQualityLevels && p.getAvailableQualityLevels()) || [];
const current = (p && p.getPlaybackQuality && p.getPlaybackQuality()) || null;
const labels = {};
const labels = {}, formats = {};
let premium = null;
const data = (p && p.getAvailableQualityData && p.getAvailableQualityData()) || [];
for (const d of data) {
if (!d || !d.quality || !d.qualityLabel) continue;
const premium = !!d.paygatedQualityDetails || /premium/i.test(d.qualityLabel);
if (!labels[d.quality] || !premium) labels[d.quality] = premium ? labels[d.quality] || d.qualityLabel : d.qualityLabel;
if (d.paygatedQualityDetails || /premium/i.test(d.qualityLabel)) {
if (!premium) premium = { quality: d.quality, label: d.qualityLabel, formatId: d.formatId || null, playable: d.isPlayable !== false, on: false };
continue;
}
if (!labels[d.quality]) { labels[d.quality] = d.qualityLabel; if (d.formatId) formats[d.quality] = d.formatId; }
}
return { current, levels, labels };
} catch (_) { return { current: null, levels: [], labels: {} }; }
if (premium) {
const now = (p.getPlaybackQualityLabel && p.getPlaybackQualityLabel()) || '';
premium.on = current === premium.quality && now === premium.label;
}
return { current, levels, labels, formats, premium };
} catch (_) { return { current: null, levels: [], labels: {}, formats: {}, premium: null }; }
}
// Choose a quality (one of the ids above). YouTube has changed which method works over
// time, so both are tried; whichever the player lacks is simply skipped.
// Choose a quality (one of the ids above, or 'premium': Premium's enhanced-bitrate one).
// Where the player gives a format id it's passed too (it tells the plain 1080p and 1080p
// Premium apart). YouTube has changed which method works over time, so both are tried;
// whichever the player lacks is simply skipped.
function setQuality(level) {
const p = player();
if (!p) return;
try { if (p.setPlaybackQualityRange) p.setPlaybackQualityRange(level, level); } catch (_) { /* older player */ }
try { if (p.setPlaybackQuality) p.setPlaybackQuality(level); } catch (_) { /* newer player */ }
const q = quality();
let id = level, formatId = q.formats[level];
if (level === 'premium') {
if (!q.premium || !q.premium.playable) return;
id = q.premium.quality; formatId = q.premium.formatId;
}
try { if (p.setPlaybackQualityRange) p.setPlaybackQualityRange(id, id, formatId || undefined); } catch (_) { /* older player */ }
if (level !== 'premium') try { if (p.setPlaybackQuality) p.setPlaybackQuality(id); } catch (_) { /* newer player */ }
}
// YouTube Premium's Jump ahead: to the point most viewers skip to. YouTube offers it as a pill
// in the player's "timely actions" overlay at the moments it applies (seen signed in with
// Premium, 2026-10-17: ytw-timely-action-view-model > button, its text "Jump ahead"), and a
// script's click on that pill jumps (17 s -> 124 s). The pill is found by its text, so this
// expects YouTube in English, like the other readers that match YouTube's words.
// jumpAheadOffered() -> is the pill on screen now? jumpAhead() clicks it (if it is).
function jumpAheadButton() {
const p = player();
if (!p) return null;
for (const b of p.querySelectorAll('.ytp-timely-actions-overlay button, ytw-timely-action-view-model button')) {
if (b.getClientRects().length && /jump ahead/i.test((b.getAttribute('aria-label') || '') + ' ' + (b.textContent || ''))) return b;
}
return null;
}
const jumpAheadOffered = () => !!jumpAheadButton();
function jumpAhead() {
const b = jumpAheadButton();
if (b) b.click();
}
// Does the signed-in account have YouTube Premium? The page's top-bar logo is YouTube
// Premium's (seen signed in with Premium, 2026-10-17: topbarLogoRenderer iconType
// 'YOUTUBE_PREMIUM_LOGO'; 'YOUTUBE_LOGO' otherwise).
function hasPremium() {
try {
const logo = window.ytInitialData && window.ytInitialData.topbar && window.ytInitialData.topbar.desktopTopbarRenderer
&& window.ytInitialData.topbar.desktopTopbarRenderer.logo;
return JSON.stringify(logo || {}).includes('YOUTUBE_PREMIUM_LOGO');
} catch (_) { return false; }
}
// Name of the channel that posted the video.
@@ -201,6 +254,39 @@
const setLoop = on => { call('setLoopVideo', [!!on]); };
// Autoplay (play the next video when this one ends): the player's own toggle button.
// The info card teaser the player shows now (its text, e.g. a linked video's title), or ''.
// YouTube's card data holds no times for the real cards, so the moment is read from its own
// player: the `.ytp-cards-teaser` element while it's laid out and visible (UNVERIFIED).
// YouTube's autoplay countdown at a video's end ("Up next in 5"), while its player shows it:
// { videoId, title, channel, seconds (null if it shows none) }, or null. Read from the
// player's own overlay (.ytp-autonav-endscreen-countdown-overlay; UNVERIFIED).
const visibleEl = sel => [...document.querySelectorAll(sel)]
.find(e => e.getClientRects().length && getComputedStyle(e).display !== 'none' && getComputedStyle(e).visibility !== 'hidden') || null;
function autoplayCountdown() {
const ov = visibleEl('#movie_player .ytp-autonav-endscreen-countdown-overlay');
if (!ov) return null;
const text = sel => { const e = ov.querySelector(sel); return e ? e.textContent.trim() : ''; };
const link = ov.querySelector('a[href*="v="]');
const videoId = ns.youtubeData.videoIdOf(link && link.getAttribute('href'));
if (!videoId) return null;
const n = /\d+/.exec(text('.ytp-autonav-endscreen-countdown') || text('.ytp-autonav-endscreen-upnext-header'));
return { videoId, title: text('.ytp-autonav-endscreen-upnext-title'), channel: text('.ytp-autonav-endscreen-upnext-author'), seconds: n ? +n[0] : null };
}
// Cancel it (its own Cancel button); true if there was one to click.
function cancelAutoplay() {
const b = visibleEl('#movie_player .ytp-autonav-endscreen-upnext-cancel-button');
if (b) b.click();
return !!b;
}
function cardTeaser() {
const el = [...document.querySelectorAll('#movie_player .ytp-cards-teaser')]
.find(e => e.getClientRects().length && getComputedStyle(e).display !== 'none' && getComputedStyle(e).visibility !== 'hidden' && +getComputedStyle(e).opacity > 0);
if (!el) return '';
const t = el.querySelector('.ytp-cards-teaser-text') || el;
return (t.textContent || '').trim();
}
const autoplayButton = () => document.querySelector('.ytp-autonav-toggle-button');
const autoplay = () => { const b = autoplayButton(); return b ? b.getAttribute('aria-checked') === 'true' : null; };
function setAutoplay(on) {
@@ -350,10 +436,10 @@
}
ns.youtube = {
player, video, call, toggle, seek, seekTo, isPaused, currentTime, duration, title, channel, quality, setQuality, info,
player, video, call, toggle, seek, seekTo, isPaused, currentTime, duration, title, channel, quality, setQuality, hasPremium, jumpAhead, jumpAheadOffered, info,
speeds, speed, setSpeed, volume, setVolume, setMuted,
captions, setCaptions, captionLines, audioTracks, setAudioTrack,
loop, setLoop, autoplay, setAutoplay, playlistPosition, nextVideo, previousVideo, playAt,
loop, setLoop, autoplay, setAutoplay, autoplayCountdown, cancelAutoplay, cardTeaser, playlistPosition, nextVideo, previousVideo, playAt,
live, goLive, sphericalView, setSphericalView, storyboard, ad, skipAd, skipHelperInstalled,
videoData, statsForNerds, aspectRatio, playerState, loadedFraction, bufferedEnd,
};
+78 -10
View File
@@ -126,19 +126,84 @@
return { current, options };
}
// channels() -> pager over the subscribed channels (the "Manage" page, browse id
// FEchannels), A-Z as YouTube lists them. p.message is the sign-in note while signed out.
function channels() {
// The Manage page's sort choices (YouTube's "A-Z" dropdown: Most relevant, New activity,
// A-Z): [{ text, selected, token }]. Seen on the signed-in page (2026-10-17): each choice is a
// listItemViewModel whose entityKey is 'channel_list_page_sort_chip_entity:<its text>';
// tapping it updates a chipEntity (the dropdown's label) and sends a browse continuation
// that reloads the list in that order. The chosen one is the chipEntity's text (or a choice
// marked isSelected).
const SORT_KEY = 'channel_list_page_sort_chip_entity:';
// Every value under `key` anywhere in `root`, however deep (the sort menu sits deeper than
// findKeyAll's 20 levels, in the dropdown's sheet).
function deepFind(root, key, out, depth = 0) {
if (!root || typeof root !== 'object' || depth > 80) return out;
if (Array.isArray(root)) { for (const x of root) deepFind(x, key, out, depth + 1); return out; }
for (const k of Object.keys(root)) { if (k === key) out.push(root[k]); else deepFind(root[k], key, out, depth + 1); }
return out;
}
function sortsOf(r) {
const items = deepFind(r, 'listItemViewModel', []).filter(it => String(it.entityKey || '').startsWith(SORT_KEY));
const chips = (dig(r, 'frameworkUpdates', 'entityBatchUpdate', 'mutations') || [])
.map(m => findKey(m.payload, 'chipEntity')).filter(Boolean);
const out = [];
for (const it of items) {
const text = strOf(it.title) || String(it.entityKey).slice(SORT_KEY.length);
if (!text || out.some(x => x.text === text)) continue;
const tap = dig(it, 'rendererContext', 'commandContext', 'onTap');
// The chipEntity this choice updates, and what that entity says now (the current choice).
const key = dig(findKey(tap, 'chipEntity'), 'key');
const now = chips.find(c => key && c.key === key);
out.push({ text, token: dig(findKey(tap, 'continuationCommand'), 'token') || null,
selected: !!(it.isSelected || it.selected || (now && strOf(now.text) === text)) });
}
return out;
}
// The channels on a Manage page, each with `group`: the title of the shelf it's in (YouTube
// splits the list into shelves, e.g. "Purchased" for channels you're a member of, then
// "Subscribed"; seen signed in, 2026-10-17), '' outside any, and `aside` (below).
function channelsOn(r) {
const out = [];
const shelves = C.findKeyAll(r, 'shelfRenderer');
for (const s of shelves) {
const group = strOf(s.title);
for (const c of C.findKeyAll(s, 'channelRenderer').map(fromChannel).filter(Boolean)) out.push({ ...c, group });
}
if (!shelves.length) out.push(...C.findKeyAll(r, 'channelRenderer').map(fromChannel).filter(Boolean).map(c => ({ ...c, group: '' })));
// `aside`: the group's title on the channels of every shelf but the last (YouTube's own
// list comes after them: "Subscribed"), so they can be told apart; '' on the rest.
const last = out.length ? out[out.length - 1].group : '';
for (const c of out) c.aside = c.group && c.group !== last ? c.group : '';
return out;
}
// channels(sort) -> pager over the subscribed channels (the "Manage" page, browse id
// FEchannels). sort: one of the page's sort choices by its text (e.g. 'A-Z'; default: the
// page's own order). After the first page p.sorts = [{ text, selected }] lists the real
// choices. p.message is the sign-in note while signed out.
function channels(sort) {
const p = D.pager(async token => {
const r = await D.innertube('browse', token ? { continuation: token } : { browseId: 'FEchannels' });
if (!token) p.message = signInMessage(r);
let r;
if (token) r = await D.innertube('browse', { continuation: token });
else {
r = await D.innertube('browse', { browseId: 'FEchannels' });
p.message = signInMessage(r);
const sorts = sortsOf(r);
p.sorts = sorts.map(({ text, selected }) => ({ text, selected }));
const s = sort && sorts.find(x => x.token && x.text.toLowerCase() === String(sort).toLowerCase() && !x.selected);
if (s) {
r = await D.innertube('browse', { continuation: s.token });
p.sorts = p.sorts.map(x => ({ text: x.text, selected: x.text === s.text }));
}
}
// Everything in this list is subscribed, whatever its button says (some only carry a
// stub that says false). notifications.options is [] when YouTube offers no bell.
const items = C.findKeyAll(r, 'channelRenderer').map(fromChannel).filter(Boolean)
.map(c => ({ ...c, subscribed: true }));
return { items, token: C.pageToken(r) };
const items = channelsOn(r).map(c => ({ ...c, subscribed: true }));
// (Its "load more" item can sit at the end of the last of several sections.)
const more = C.findKeyAll(r, 'continuationItemRenderer').pop();
return { items, token: C.pageToken(r) || (more ? D.continuationOf({ continuationItemRenderer: more }) : null) };
}, null);
p.message = '';
p.message = ''; p.sorts = [];
return p;
}
@@ -186,6 +251,9 @@
// Which reported states mean each menu label. Seen signed in: All = ALL, Personalized =
// OCCASIONAL (0.20.5). None wasn't seen yet, so close variants are accepted.
const BELL_STATES = { all: /^ALL$/, personalized: /PERSONALI[SZ]ED|DEFAULT|OCCASIONAL/, none: /OFF|NONE|NO_NOTIFICATIONS/ };
// Does a bell state ('ALL', 'OCCASIONAL' ...) mean the menu choice called `label`? (false
// for a label it doesn't know).
const bellMatches = (label, state) => { const re = BELL_STATES[String(label).toLowerCase()]; return !!(re && state && re.test(state)); };
// setNotifications(option) -> Promise of { ok, confirmed, state?, error? }: choose a bell
// setting, where `option` is one of a channel's notifications.options (from channels()).
@@ -204,5 +272,5 @@
} catch (err) { return { ok: false, confirmed: false, error: String(err && err.message || err) }; }
}
ns.youtubeSubscriptions = { feed, channels, subscribe, unsubscribe, setNotifications, fromChannel, checkSignIn, checkAccount, answeredSignedIn, signedIn: D.signedIn };
ns.youtubeSubscriptions = { feed, channels, subscribe, unsubscribe, setNotifications, bellMatches, fromChannel, checkSignIn, checkAccount, answeredSignedIn, signedIn: D.signedIn };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+29 -8
View File
@@ -1,19 +1,22 @@
// The "2D Cinema" settings tab, which belongs to the shell (the cinema screen is the
// shell's, whatever the site): Curve on/off, Curve intensity, Distance, Glow on/off, Glow
// strength and Glow length, all dimmed and inactive unless the cinema screen is showing
// (intensity also needs Curve on, the glow's sliders Glow on). Each slider has a Default
// button. A site's Options panel includes it as one of its tabs.
// shell's, whatever the site): Follow your head on/off (a 2D video's screen and the panels
// catching up as you turn: xr-session.js; switchable any time) with its Delay and Speed, then Curve on/off, Curve
// intensity, Distance, Glow on/off, Glow strength and Glow length, all dimmed and inactive
// unless the cinema screen is showing (intensity also needs Curve on, the glow's sliders Glow
// on). Each slider has a Default button. A site's Options panel includes it as one of its tabs.
//
// ns.vrCinemaOptions.content(viewSettings) -> the Options style's 'rows' content
// ns.vrCinemaOptions.event(ev, viewSettings, actions) -> true if it was one of this tab's
// controls ('switch:curve', 'slider:curveAmount' / 'slider:distance', 'switch:glow',
// controls ('switch:follow', 'slider:followAfter' / 'slider:followRate', 'switch:curve',
// 'slider:curveAmount' / 'slider:distance', 'switch:glow',
// 'slider:glowStrength', 'slider:glowReach', 'default:...')
// viewSettings: { distance: { value, min, max, default } (metres) | null (not on the
// cinema screen), cinema: { active, on, amount, defaultAmount, format (the
// cinema screen), cinema: { active, follow: { on, after, rate } (each a
// slider's { value, min, max, default }: seconds, 1/s), on, amount, defaultAmount, format (the
// current format's name, shown as the tab's note), glow, glowStrength,
// defaultGlowStrength, glowReach: { value, min, max, default } (a share
// of the screen's height) } }
// actions used: setCurve({ on?, amount? }), setGlow({ on?, strength?, reach? }), setDistance(metres).
// actions used: setFollow({ on?, after?, rate? }), setCurve({ on?, amount? }), setGlow({ on?, strength?, reach? }), setDistance(metres).
(function (ns) {
'use strict';
@@ -24,14 +27,26 @@
// The Glow length slider: fraction 0..1 <-> its reach (a share of the screen's height), straight.
const reachToFraction = r => (r.value - r.min) / (r.max - r.min);
const fractionToReach = (r, f) => r.min + f * (r.max - r.min);
// Follow your head's sliders: Delay straight, Speed logarithmic ({ value, min, max } <-> 0..1).
const linTo = s => (s.value - s.min) / (s.max - s.min), linFrom = (s, f) => s.min + f * (s.max - s.min);
const logTo = s => Math.log(s.value / s.min) / Math.log(s.max / s.min), logFrom = (s, f) => s.min * Math.pow(s.max / s.min, f);
const tr = (key, vars) => ns.i18n.t(key, vars); // words in the interface language (i18n.js)
function content(vs) {
const c = (vs && vs.cinema) || { active: false, on: false, amount: 0 };
const fo = c.follow || null; // Follow your head: { on, after, rate } (each a slider's { value, min, max, default })
const d = vs && vs.distance;
return {
kind: 'rows', note: tr(c.active ? 'cinema.now' : 'cinema.nowNotFlat', { format: c.format || '' }),
rows: [
// (works on any 2D video, so it can be switched while another format shows)
{ label: tr('cinema.follow'), hint: tr('cinema.followHint'), control: { type: 'switch', id: 'follow', on: !!(fo && fo.on) } },
{ label: tr('cinema.followDelay'), dim: !(fo && fo.on),
control: { type: 'slider', id: 'followAfter', fraction: fo ? linTo(fo.after) : 0, text: fo ? `${fo.after.value.toFixed(1)} s` : '',
enabled: !!(fo && fo.on), defaultButton: true, ends: [tr('cinema.sooner'), tr('cinema.later')] } },
{ label: tr('cinema.followSpeed'), dim: !(fo && fo.on),
control: { type: 'slider', id: 'followRate', fraction: fo ? logTo(fo.rate) : 0, text: fo ? `${Math.round(logTo(fo.rate) * 100)}%` : '',
enabled: !!(fo && fo.on), defaultButton: true, ends: [tr('cinema.slower'), tr('cinema.faster')] } },
{ label: tr('cinema.curve'), dim: !c.active, control: { type: 'switch', id: 'curve', on: c.on, enabled: c.active } },
{ label: tr('cinema.curveAmount'), dim: !(c.active && c.on),
control: { type: 'slider', id: 'curveAmount', fraction: c.amount, text: `${Math.round(c.amount * 100)}%`,
@@ -54,7 +69,13 @@
function event(ev, vs, actions) {
const c = (vs && vs.cinema) || null, d = vs && vs.distance;
if (ev.type === 'switch' && ev.value === 'curve') actions.setCurve({ on: !(c && c.on) });
const fo = c && c.follow;
if (ev.type === 'switch' && ev.value === 'follow') actions.setFollow({ on: !(fo && fo.on) });
else if (ev.type === 'slider' && ev.value === 'followAfter') { if (fo) actions.setFollow({ after: linFrom(fo.after, ev.fraction) }); }
else if (ev.type === 'default' && ev.value === 'followAfter') { if (fo) actions.setFollow({ after: fo.after.default }); }
else if (ev.type === 'slider' && ev.value === 'followRate') { if (fo) actions.setFollow({ rate: logFrom(fo.rate, ev.fraction) }); }
else if (ev.type === 'default' && ev.value === 'followRate') { if (fo) actions.setFollow({ rate: fo.rate.default }); }
else if (ev.type === 'switch' && ev.value === 'curve') actions.setCurve({ on: !(c && c.on) });
else if (ev.type === 'default' && ev.value === 'distance') { if (d) actions.setDistance(d.default); }
else if (ev.type === 'default' && ev.value === 'curveAmount') { if (c) actions.setCurve({ amount: c.defaultAmount }); }
else if (ev.type === 'slider' && ev.value === 'distance') { if (d) actions.setDistance(fractionToDist(d, ev.fraction)); }
+61 -17
View File
@@ -1,6 +1,6 @@
// Shows a regular (flat) video on a big screen in front of you, like a cinema. Same
// interface as the video sphere: upload(video), draw(viewerPose, layer), backdrop() (for the
// panels' glass), place(head, yaw),
// panels' glass), place(head, yaw, pitch),
// dispose(); plus reset() (the global reset: default size, curve and glow). Unlike the sphere, the screen stays put in the room, so you can lean and
// look around it.
//
@@ -33,6 +33,19 @@
minGlowReach: 0.1, // limits for the Glow length slider
maxGlowReach: 0.8,
glowDetail: 32, // how blurred its colours are: read from the mipmap level about this many texels wide
// Follow your head (a 2D video, with the setting on: xr-session.js): a lazy follow. Once you've
// faced more than followStart (sideways) or followStartUp (up or down) away from the layout
// for its delay (not while using or looking at a panel), the screen and the panels turn to
// where you face, easing in at its speed (the share of the way left covered per second,
// as an exponential), until within followDone. Delay and speed are settings (sliders).
follow: false, // off by default
followStart: 35, // degrees
followStartUp: 25, // degrees
followAfter: 0.6, // seconds (default)
minFollowAfter: 0.2, maxFollowAfter: 3, // the Delay slider's limits
followRate: 2, // 1/s (default)
minFollowRate: 0.5, maxFollowRate: 6, // the Speed slider's limits
followDone: 2, // degrees
storageKey: 'cinemaScreen', // storage.js name for the remembered size/placement/curve
};
@@ -85,7 +98,9 @@
// as uSize), d its distance
// outside a rounded rectangle of corner radius uRadius (Inigo Quilez's sdRoundBox); the
// last 4 mm inside the edge fade out (alpha, blended over what's behind without touching
// the frame's alpha: see draw), and outside it's discarded.
// the frame's alpha: see draw), and outside it's discarded. The video is read at this eye's
// part of the frame across (uUvX: scale, offset; the whole frame, or a 3D side-by-side
// frame's half).
const VS = `
attribute vec2 aCorner; // -0.5..0.5
uniform mat4 uProj, uView, uModel; uniform vec2 uSize; uniform float uBend;
@@ -101,14 +116,14 @@
gl_Position = uProj * uView * uModel * vec4(p, 1.0);
}`;
const FS = `
precision mediump float; uniform sampler2D uTex; uniform vec2 uScreen; uniform float uRadius; varying vec2 vUv;
precision mediump float; uniform sampler2D uTex; uniform vec2 uScreen, uUvX; uniform float uRadius; varying vec2 vUv;
void main() {
vec2 p = (vUv - 0.5) * uScreen;
vec2 q = abs(p) - uScreen * 0.5 + uRadius;
float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - uRadius;
float a = 1.0 - smoothstep(-0.004, 0.0, d);
if (a <= 0.0) discard;
gl_FragColor = vec4(texture2D(uTex, vUv).rgb, a);
gl_FragColor = vec4(texture2D(uTex, vec2(vUv.x * uUvX.x + uUvX.y, vUv.y)).rgb, a);
}`;
// The glow (WebGL 2 shaders, GLSL ES 3.00, for textureLod): the same strip, bigger (uSize,
@@ -120,7 +135,8 @@
// texture coordinate clamped to the picture) from the mipmap level about uDetail texels
// wide (blurred; a level further up further out: chosen here rather than by the GPU, which
// would pick the sharpest level where the clamped coordinate doesn't change), fading out to
// nothing at uReach metres. Added to what's behind (light), at uStrength.
// nothing at uReach metres. Added to what's behind (light), at uStrength. Read at this eye's
// part of the frame across (uUvX), as FS.
const GLOW_VS = `#version 300 es
in vec2 aCorner;
uniform mat4 uProj, uView, uModel; uniform vec2 uSize; uniform float uBend;
@@ -137,7 +153,7 @@
}`;
const GLOW_FS = `#version 300 es
precision mediump float;
uniform sampler2D uTex; uniform vec2 uGlow, uScreen; uniform float uRadius, uReach, uDetail, uStrength;
uniform sampler2D uTex; uniform vec2 uGlow, uScreen, uUvX; uniform float uRadius, uReach, uDetail, uStrength;
in vec2 vUv;
out vec4 outColor;
void main() {
@@ -149,6 +165,7 @@
float a = uStrength * (1.0 - t) * (1.0 - t);
if (a <= 0.002) discard;
vec2 uv = clamp(p / uScreen + 0.5, 0.0, 1.0);
uv.x = uv.x * uUvX.x + uUvX.y;
float lod = log2(max(1.0, float(textureSize(uTex, 0).x) / uDetail)) + t * 1.5;
outColor = vec4(textureLod(uTex, uv, lod).rgb, a);
}`;
@@ -201,18 +218,20 @@
return new Float32Array(out);
}
// Build the cinema screen renderer. Returns { upload, draw, place, dispose, interaction,
// Build the cinema screen renderer for format `fmt` ('flat', or 'flatsbs': 3D side by side,
// each eye its half of the frame). Returns { upload, draw, place, dispose, interaction,
// pauseAnchor, distance, setDistance, setCurve }: the same drawing interface as the video
// sphere, plus `interaction` (the laser-grabbable handles), `pauseAnchor` (where the pause
// symbol goes) and the Settings controls.
function createCinemaScreen(gl) {
function createCinemaScreen(gl, fmt = 'flat') {
const spans = ns.formats.eyeSpans(fmt); // which part of the frame each eye sees (3D side by side: its half)
const prog = G.createProgram(gl, VS, FS, 'cinema screen');
const hprog = G.createProgram(gl, VS, HANDLE_FS, 'cinema handles');
const isGL2 = typeof WebGL2RenderingContext !== 'undefined' && gl instanceof WebGL2RenderingContext;
const gprog = isGL2 ? G.createProgram(gl, GLOW_VS, GLOW_FS, 'cinema glow') : null; // (no glow on WebGL 1)
const U = G.uniforms(gl, prog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', tex: 'uTex', screen: 'uScreen', radius: 'uRadius' });
const U = G.uniforms(gl, prog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', tex: 'uTex', screen: 'uScreen', radius: 'uRadius', uvX: 'uUvX' });
const GU = gprog && G.uniforms(gl, gprog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', tex: 'uTex', glow: 'uGlow',
screen: 'uScreen', radius: 'uRadius', reach: 'uReach', detail: 'uDetail', strength: 'uStrength' });
screen: 'uScreen', radius: 'uRadius', reach: 'uReach', detail: 'uDetail', strength: 'uStrength', uvX: 'uUvX' });
const HU = G.uniforms(gl, hprog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', shape: 'uShape', arc: 'uArc', color: 'uColor', alpha: 'uAlpha' });
const vaos = G.vertexArrays(gl);
const corners = new Float32Array([-0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5]);
@@ -237,6 +256,12 @@
const prefs = loadPrefs();
let width = clampWidth(prefs.width || SETTINGS.width);
let aspect = 16 / 9;
// The screen's shape from the frame's (width / height): 3D side by side, one eye's picture:
// half the frame when it's two full pictures (wider than 2.5:1, e.g. 32:9), else the frame
// itself (half side by side: each eye squeezed into half of a 16:9 frame, shown at 16:9).
const eyeAspect = frame => (ns.formats.isSbs(fmt) && frame > 2.5 ? frame / 2 : frame);
// This eye's part of the frame across ([scale, offset]; a headset that names no eye: the left).
const eyeSpan = view => (view.eye === 'right' ? spans.right : spans.left);
let model = M.translation(0, 0, -SETTINGS.distance); // centre + orientation (+Z faces you)
let dist = clampDistance(prefs.distance || SETTINGS.distance);
let curve = curvePrefs(); // { on, amount }
@@ -274,12 +299,14 @@
// Put the screen straight ahead of where you're looking (on enter, recenter, or switching to it),
// at your remembered distance and height.
function place(head, yaw) {
function place(head, yaw, pitch = 0) {
const p = loadPrefs();
const raise = p.raise != null ? p.raise : SETTINGS.raise;
dist = clampDistance(p.distance || SETTINGS.distance);
const c = Math.cos(yaw), s = Math.sin(yaw);
faceToward([head.x - s * dist, head.y + raise, head.z - c * dist], [head.x, head.y, head.z]);
// (pitch: the whole placement turned up or down about your eyes, e.g. by the lazy follow)
const r = Math.hypot(dist, raise), e = Math.atan2(raise, dist) + pitch;
const c = Math.cos(yaw), s = Math.sin(yaw), h = r * Math.cos(e);
faceToward([head.x - s * h, head.y + r * Math.sin(e), head.z - c * h], [head.x, head.y, head.z]);
}
// Settings: the current distance, the slider's limits and the default, in metres.
@@ -322,7 +349,7 @@
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);
mipped = G.videoMipmaps(gl, ns.vrTokens.glass.on || glow.on);
if (video.videoWidth && video.videoHeight) aspect = video.videoWidth / video.videoHeight;
if (video.videoWidth && video.videoHeight) aspect = eyeAspect(video.videoWidth / video.videoHeight);
}
// What's behind the panels, for the glass look (vr-panel/panel-pass.js): the video's
@@ -336,7 +363,7 @@
if (!mipped) return null;
const g = gprog && glow.on && glow.strength > 0
? { strength: glow.strength, reach: height() * glow.reach, radius: width * SETTINGS.cornerRadius } : null;
return { kind: 'screen', tex, centre: centre(), right: axis(0), up: axis(1), normal: axis(2), size: [width, height()], bend: bend(), glow: g };
return { kind: 'screen', tex, centre: centre(), right: axis(0), up: axis(1), normal: axis(2), size: [width, height()], bend: bend(), glow: g, uvX: spans };
}
// The handles: { id, frame (a point on the screen's centre line, see edgeFrame), x, y
@@ -409,6 +436,7 @@
gl.uniform1f(GU.reach, reach);
gl.uniform1f(GU.detail, SETTINGS.glowDetail);
gl.uniform1f(GU.strength, glow.strength);
gl.uniform2fv(GU.uvX, eyeSpan(view));
gl.enable(gl.BLEND);
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE, gl.ZERO, gl.ONE);
gl.drawArrays(gl.TRIANGLES, 0, SETTINGS.columns * 6);
@@ -434,6 +462,7 @@
gl.uniform2f(U.size, width, height());
gl.uniform2f(U.screen, width, height());
gl.uniform1f(U.radius, width * SETTINGS.cornerRadius);
gl.uniform2fv(U.uvX, eyeSpan(view));
gl.uniform1f(U.bend, b);
gl.uniformMatrix4fv(U.proj, false, view.projectionMatrix);
gl.uniformMatrix4fv(U.view, false, view.transform.inverse.matrix);
@@ -565,14 +594,29 @@
return { upload, draw, backdrop, place, dispose, interaction, pauseAnchor, captionAnchor, distance, setDistance, setCurve, setGlow, reset };
}
// Follow your head on a 2D video: on / off, its delay and speed (remembered):
// { on, after (s), rate (1/s) }, and storing new ones (`patch`: { on?, after?, rate? }).
function followPrefs() {
const p = loadPrefs();
return { on: p.follow != null ? !!p.follow : SETTINGS.follow, after: p.followAfter != null ? p.followAfter : SETTINGS.followAfter,
rate: p.followRate != null ? p.followRate : SETTINGS.followRate };
}
function saveFollow(patch) {
const f = { ...followPrefs(), ...patch };
f.after = Math.min(SETTINGS.maxFollowAfter, Math.max(SETTINGS.minFollowAfter, f.after));
f.rate = Math.min(SETTINGS.maxFollowRate, Math.max(SETTINGS.minFollowRate, f.rate));
savePrefs({ follow: !!f.on, followAfter: f.after, followRate: f.rate });
return f;
}
// The right renderer for a format: cinema screen for flat videos, sphere for the rest.
function createVideoView(gl, fmt) {
return ns.formats.isFlat(fmt) ? createCinemaScreen(gl) : ns.videoSphere.createVideoSphere(gl, fmt);
return ns.formats.isFlat(fmt) ? createCinemaScreen(gl, fmt) : ns.videoSphere.createVideoSphere(gl, fmt);
}
// The global reset: forget the remembered size, placement and curve.
const resetPrefs = () => ns.storage.remove(SETTINGS.storageKey);
ns.cinemaScreen = { createCinemaScreen, SETTINGS, curvePrefs, saveCurve, glowPrefs, saveGlow, resetPrefs };
ns.cinemaScreen = { createCinemaScreen, SETTINGS, curvePrefs, saveCurve, glowPrefs, saveGlow, followPrefs, saveFollow, resetPrefs };
ns.videoView = { create: createVideoView };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+18 -9
View File
@@ -9,9 +9,9 @@
const { formats, gl: G } = ns;
// Vertex shader: passes the vertex position along as the view direction (vDir) for the
// cubemap lookup, and applies uUvXform (scale xy, offset zw) to the texture coordinates;
// it's [1,1,0,0] ("whole frame") now that 3D layouts are gone, but it's where per-eye
// cropping would plug in.
// cubemap lookup, and applies uUvXform (scale xy, offset zw) to the texture coordinates:
// [1,1,0,0] for the whole frame, or one eye's half of a side-by-side 3D frame
// (formats.eyeSpans).
const VS = `
attribute vec3 aPos; attribute vec2 aUv;
uniform mat4 uProj, uView, uModel; uniform vec4 uUvXform;
@@ -26,6 +26,10 @@
// Cubemap layout, confirmed against a real YouTube frame (same as ffmpeg's "eac"):
// top row left | front | right
// bottom row down | back | up (down and up turned 270° clockwise, back 90°)
// The cubemap is read at full resolution (a -16 mipmap bias pins it to level 0): where two
// faces meet, the lookup jumps across the texture, and with the glass's mipmaps
// (gl-utils.js videoMipmaps) the GPU would read its smallest level along those lines,
// drawing seams along the cube's edges. Level 0 is what it reads with no mipmaps anyway.
const FS = `
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
@@ -57,8 +61,8 @@
}
void main() {
vec2 uv = uMode > 0.5 ? eacUv(normalize(vDir)) : vUv;
gl_FragColor = texture2D(uTex, uv);
if (uMode > 0.5) gl_FragColor = texture2D(uTex, eacUv(normalize(vDir)), -16.0);
else gl_FragColor = texture2D(uTex, vUv);
}`;
// Inside-facing sphere. Longitude 0 = straight ahead (-Z), positive = to the right.
@@ -97,6 +101,7 @@
const vao = vaos ? vaos.create() : null;
if (vaos) vaos.bind(vao);
const mesh = buildSphere(formats.longitudeSpan(fmt));
const spans = formats.eyeSpans(fmt); // which part of the frame each eye sees
const buffers = [
G.floatAttribute(gl, prog, 'aPos', mesh.pos, 3),
G.floatAttribute(gl, prog, 'aUv', mesh.uv, 2),
@@ -117,13 +122,14 @@
// What's behind the panels, for the glass look (vr-panel/panel-pass.js): the video's
// texture and how it's mapped ('sphere': equirectangular over `span` radians of
// longitude; 'eac': the cubemap), turned by `yaw`. Builds the mipmaps at once if the
// glass was just turned on. null while the glass is off.
// longitude, `uvX` each eye's part of the frame across: { left, right }; 'eac': the
// cubemap), turned by `yaw`. Builds the mipmaps at once if the glass was just turned on.
// null while the glass is off.
function backdrop() {
if (!ns.vrTokens.glass.on) return null;
if (!mipped) { gl.bindTexture(gl.TEXTURE_2D, tex); mipped = G.videoMipmaps(gl, true); }
if (!mipped) return null;
return { kind: formats.projectionMode(fmt) > 0.5 ? 'eac' : 'sphere', tex, span: formats.longitudeSpan(fmt), yaw: lastYaw };
return { kind: formats.projectionMode(fmt) > 0.5 ? 'eac' : 'sphere', tex, span: formats.longitudeSpan(fmt), uvX: spans, yaw: lastYaw };
}
// The video is "at infinity": only the head's rotation matters, not its position.
@@ -147,7 +153,10 @@
m[12] = m[13] = m[14] = 0; // drop the head's position, keep only its rotation
gl.uniformMatrix4fv(U.proj, false, view.projectionMatrix);
gl.uniformMatrix4fv(U.view, false, m);
gl.uniform4fv(U.xf, [1, 1, 0, 0]); // whole frame for both eyes (YouTube sends one eye)
// This eye's part of the frame (side by side: its half; a headset that names no eye
// gets the left one).
const [sx, ox] = view.eye === 'right' ? spans.right : spans.left;
gl.uniform4fv(U.xf, [sx, 1, ox, 0]);
gl.drawElements(gl.TRIANGLES, mesh.idx.length, gl.UNSIGNED_SHORT, 0);
}
if (vaos) vaos.bind(null);
+93 -6
View File
@@ -28,6 +28,32 @@
return cv;
}
// A poster for the screen: the first of `urls` that loads (with CORS, so it can go to WebGL),
// drawn whole on a dark 16:9 frame (as the placeholder). Returns a Promise of the canvas, or
// of null if none loads.
async function posterCanvas(urls) {
const load = url => new Promise(res => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => res(img.naturalWidth > 120 ? img : null); // (YouTube answers a missing size with a tiny grey image)
img.onerror = () => res(null);
img.src = url;
});
for (const url of urls) {
const img = await load(url);
if (!img) continue;
const W = 1920, H = 1080, cv = document.createElement('canvas');
cv.width = W; cv.height = H;
const g = cv.getContext('2d');
g.fillStyle = (ns.vrTokens && ns.vrTokens.m3.surfaceContainerLowest) || '#0f0f0f';
g.fillRect(0, 0, W, H);
const k = Math.min(W / img.naturalWidth, H / img.naturalHeight), w = img.naturalWidth * k, h = img.naturalHeight * k;
g.drawImage(img, (W - w) / 2, (H - h) / 2, w, h);
return cv;
}
return null;
}
// Start VR showing the provider's video in `fmt`. listener(state) gets
// { state: 'format', fmt } when the format is changed from inside VR,
// { state: 'ended' } or { state: 'error', message } later on. Must be called from a user gesture.
@@ -72,8 +98,11 @@
// While no video plays: the placeholder picture (the site's logo, centred on a dark
// 16:9 frame: placeholderCanvas) on the screen, uploaded once per view.
let placeholder = null, placeholderFor = null;
// While the site won't play its video (siteSide.poster(): picture URLs, best first, or
// null): that picture on the screen instead (posterCanvas), uploaded once per view.
let poster = { key: '', canvas: null, for: null };
placeholderCanvas(provider).then(c => { placeholder = c; }, () => {});
let lastHead = null; // where you were (and faced) at the last recenter: { position, yaw }
let lastHead = null; // where you were (and faced) at the last recenter: { position, yaw, pitch }
// Formats offered in the VR panel, named in the interface language (asked each frame,
// so a language change shows at once).
@@ -87,7 +116,7 @@
if (!ns.formats.isFormat(next) || next === fmt) return;
const old = view;
view = ns.videoView.create(gl, next);
if (view.place && lastHead) view.place(lastHead.position, lastHead.yaw);
if (view.place && lastHead) view.place(lastHead.position, lastHead.yaw, lastHead.pitch || 0);
old.dispose();
panelSafely('targets', () => panel.setTargets([view.interaction]));
fmt = next;
@@ -168,9 +197,16 @@
return { glow: g.on, glowStrength: g.strength, defaultGlowStrength: S.glowStrength,
glowReach: { value: g.reach, min: S.minGlowReach, max: S.maxGlowReach, default: S.glowReach } };
};
// Follow your head's, as the 2D Cinema tab wants them: { on, after, rate } with each
// slider's { value, min, max, default }.
const followView = () => {
const f = ns.cinemaScreen.followPrefs(), S = ns.cinemaScreen.SETTINGS;
return { on: f.on, after: { value: f.after, min: S.minFollowAfter, max: S.maxFollowAfter, default: S.followAfter },
rate: { value: f.rate, min: S.minFollowRate, max: S.maxFollowRate, default: S.followRate } };
};
const viewSettings = () => ({
distance: view.distance ? view.distance() : null,
cinema: { active: ns.formats.isFlat(fmt), ...ns.cinemaScreen.curvePrefs(), defaultAmount: ns.cinemaScreen.SETTINGS.curveAmount,
cinema: { active: ns.formats.isFlat(fmt), follow: followView(), ...ns.cinemaScreen.curvePrefs(), defaultAmount: ns.cinemaScreen.SETTINGS.curveAmount,
...glowSettings(),
format: ns.formats.label(fmt) },
});
@@ -195,6 +231,7 @@
setFormat: next => setFormat(next, true),
setDistance: metres => { if (view.setDistance) view.setDistance(metres, headNow); },
setCurve, setGlow,
setFollow: patch => { ns.cinemaScreen.saveFollow(patch); followPrefAt = 0; }, // (Follow your head { on?, after?, rate? }: read again at once)
state: () => ({ ...siteSide.state(), viewSettings: viewSettings(),
format: { current: fmt, options: formatOptions() } }), // what's on screen (cinema during ads)
}, siteSide.site) : null;
@@ -253,6 +290,44 @@
} catch (_) { /* no haptics */ }
};
// Follow your head (Settings → 2D Cinema, on a 2D video only): a lazy follow. While you
// face more than followStart sideways (or followStartUp up or down) away from where the
// layout faces, for the Delay setting's seconds (not while using a panel: pointing at
// one, holding or dragging something, nor while looking at one), the screen and the
// panels turn to where you face, easing in at the Speed setting, until within
// followDone; glances don't move anything. Turns `yaw` and `pitch` (and lastHead) as a
// recenter would, keeping where you were standing at the last one. (cinema-screen.js
// SETTINGS: the numbers.) A recenter sets the pitch back to level.
const D2R = Math.PI / 180;
let awaySince = 0, following = false, lastFollowAt = 0, followPrefAt = 0, pitch = 0;
let follow = { on: false, after: 1, rate: 2 };
const wrapAngle = a => Math.atan2(Math.sin(a), Math.cos(a));
function followHead(pose) {
const now = performance.now(), dt = lastFollowAt ? Math.min(0.1, (now - lastFollowAt) / 1000) : 0;
lastFollowAt = now;
const S = ns.cinemaScreen.SETTINGS, videoFmt = adFmt !== null ? adFmt : fmt;
if (now - followPrefAt > 500) { follow = ns.cinemaScreen.followPrefs(); followPrefAt = now; } // (the settings, twice a second)
const m = pose.transform.matrix, gaze = { origin: [m[12], m[13], m[14]], dir: [-m[8], -m[9], -m[10]] };
if (!lastHead || videoFmt !== 'flat' || !follow.on || (panel && panel.engaged(gaze))) {
awaySince = 0; following = false; return;
}
const offYaw = wrapAngle(M.yawOf(pose.transform.orientation) - yaw);
const offPitch = Math.asin(Math.max(-1, Math.min(1, gaze.dir[1]))) - pitch;
if (!following) {
if (Math.abs(offYaw) < S.followStart * D2R && Math.abs(offPitch) < S.followStartUp * D2R) { awaySince = 0; return; }
if (!awaySince) awaySince = now;
if (now - awaySince < follow.after * 1000) return;
following = true;
}
const k = 1 - Math.exp(-follow.rate * dt);
yaw = wrapAngle(yaw + offYaw * k); pitch += offPitch * k;
if (Math.abs(offYaw) < S.followDone * D2R && Math.abs(offPitch) < S.followDone * D2R) { following = false; awaySince = 0; }
lastHead = { position: lastHead.position, yaw, pitch };
try { if (view.place) view.place(lastHead.position, yaw, pitch); } catch (err) { console.warn('[streamxr] placing the video failed:', err); }
panelSafely('placement', () => panel.place(lastHead.position, yaw, pitch));
}
// Opening another video (from a panel, or the site's autoplay) happens inside the page,
// so the session keeps running. Re-check the format for the new video. (If an ad plays
// first, the check runs again when it ends.)
@@ -269,6 +344,13 @@
const hp = pose.transform.position;
headNow = [hp.x, hp.y, hp.z];
if (source.follow()) placeholderFor = null; // the site swapped its <video> (or a video opened / went away)
// The site's poster (a video it won't play): load it when it changes; none: the video again.
const posterUrls = (siteSide.poster && siteSide.poster()) || null, posterKey = posterUrls ? posterUrls.join(' ') : '';
if (posterKey !== poster.key) {
const p = poster = { key: posterKey, canvas: null, for: null };
if (posterUrls) posterCanvas(posterUrls).then(c => { p.canvas = c; }, () => {});
else { placeholderFor = null; source.invalidate(); } // (the video's frame again, even paused)
}
input.poll(session.inputSources);
try { followAds(); } catch (err) { console.warn('[streamxr] ad handling failed:', err); }
// Recenter: take the direction you're facing now as "front", and put the video and
@@ -276,15 +358,20 @@
if (wantRecenter) {
yaw = M.yawOf(pose.transform.orientation);
wantRecenter = false;
lastHead = { position: pose.transform.position, yaw };
pitch = 0;
lastHead = { position: pose.transform.position, yaw, pitch };
try { if (view.place) view.place(lastHead.position, yaw); } catch (err) { console.warn('[streamxr] placing the video failed:', err); }
panelSafely('placement', () => panel.place(pose.transform.position, yaw));
}
followHead(pose);
panelSafely('update', () => panel.update(frame, refSpace, session.inputSources, performance.now(), pose.transform.position));
// Copy the video's newest frame to the GPU (only when there is one, and the video
// has data). A refused frame (cross-origin) ends the session with a message.
if (source.hasNewFrame()) {
// has data). A refused frame (cross-origin) ends the session with a message. While
// the site gives a poster, that shows instead (once per view).
if (poster.key) {
if (poster.canvas && poster.for !== view) { view.upload(poster.canvas); poster.for = view; }
} else if (source.hasNewFrame()) {
try {
view.upload(source.element());
source.uploaded();
+17 -8
View File
@@ -2,13 +2,16 @@
// both worlds (the panel lists the formats; the renderer needs the projection). Which
// format a video is, is decided by the site's provider (detectFormat).
//
// There are no 3D formats: on desktop YouTube sends one eye only, even for 3D videos.
// 3D: most 3D videos reach the desktop as one eye only (shown as mono); some arrive with both
// eyes side by side in one frame, which '180sbs' (VR180) and 'flatsbs' (the cinema screen)
// give each eye its half of. Nothing in the site's data marks those, so they're picked by hand
// (docs/youtube.md, "Formats and VR180").
(function (ns) {
'use strict';
// The four formats. The ids are used everywhere: 'mono' = one picture for both eyes;
// 'eac' = equi-angular cubemap.
const IDS = Object.freeze(['flat', '360mono', '360eac', '180mono']);
// The six formats. The ids are used everywhere: 'mono' = one picture for both eyes;
// 'eac' = equi-angular cubemap; 'sbs' = 3D side by side (left eye's picture on the left).
const IDS = Object.freeze(['flat', 'flatsbs', '360mono', '360eac', '180mono', '180sbs']);
const DEFAULT = 'flat'; // when nothing is known about the video
const isFormat = id => IDS.includes(id);
@@ -22,14 +25,20 @@
// sites/youtube/page/provider/provider.js.)
// Horizontal span of the sphere, in radians (the cinema screen doesn't use a sphere).
const longitudeSpan = fmt => (fmt === '180mono' ? Math.PI : Math.PI * 2);
const longitudeSpan = fmt => (fmt === '180mono' || fmt === '180sbs' ? Math.PI : Math.PI * 2);
// Which part of the frame each eye sees, across: [scale, offset] for the left eye and the
// right eye ([1, 0] = the whole frame; side by side, the left and right halves).
const isSbs = fmt => fmt === '180sbs' || fmt === 'flatsbs';
const eyeSpans = fmt => (isSbs(fmt) ? { left: [0.5, 0], right: [0.5, 0.5] } : { left: [1, 0], right: [1, 0] });
// How the sphere maps the frame: 0 = one wrap-around picture (equirectangular),
// 1 = YouTube's equi-angular cubemap.
const projectionMode = fmt => (fmt === '360eac' ? 1 : 0);
// Flat videos go on the cinema screen; everything else on the video sphere.
const isFlat = fmt => fmt === 'flat';
// Flat videos (2D, or 3D side by side) go on the cinema screen; everything else on the
// video sphere.
const isFlat = fmt => fmt === 'flat' || fmt === 'flatsbs';
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, projectionMode, isFlat };
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, eyeSpans, isSbs, projectionMode, isFlat };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+12
View File
@@ -15,13 +15,17 @@
// Video formats: the full names (the popup's picker) and short ones (the VR panel's
// Format button; \n = line break).
'format.flat': 'Cinema screen (regular video)',
'format.flatsbs': 'Cinema screen 3D side by side (both eyes in one frame)',
'format.360mono': '360°',
'format.360eac': '360° cubemap (YouTube "mesh")',
'format.180mono': '180° (VR180)',
'format.180sbs': '180° 3D side by side (both eyes in one frame)',
'format.short.flat': 'Cinema\nscreen',
'format.short.flatsbs': 'Cinema 3D\nside by side',
'format.short.360mono': '360°',
'format.short.360eac': '360°\ncubemap',
'format.short.180mono': '180°',
'format.short.180sbs': '180° 3D\nside by side',
// The popup on the video page, and the player's VR button.
'popup.button': 'Watch in VR (SteamVR)',
@@ -60,6 +64,14 @@
// The 2D Cinema settings tab.
'cinema.now': 'Now: {format}',
'cinema.nowNotFlat': 'Now: {format} · these work on the cinema screen (2D videos) only',
'cinema.follow': 'Follow your head',
'cinema.followHint': 'On a 2D video, the screen and panels catch up when you turn',
'cinema.followDelay': 'Follow delay',
'cinema.followSpeed': 'Follow speed',
'cinema.sooner': 'Sooner',
'cinema.later': 'Later',
'cinema.slower': 'Slower',
'cinema.faster': 'Faster',
'cinema.curve': 'Curve',
'cinema.curveAmount': 'Curve intensity',
'cinema.distance': 'Distance',
+12
View File
@@ -7,13 +7,17 @@
ns.i18n.add('es', {
// Video formats.
'format.flat': 'Pantalla de cine (vídeo normal)',
'format.flatsbs': 'Pantalla de cine 3D lado a lado (los dos ojos en un fotograma)',
'format.360mono': '360°',
'format.360eac': '360° cubemap (YouTube "mesh")',
'format.180mono': '180° (VR180)',
'format.180sbs': '180° 3D lado a lado (los dos ojos en un fotograma)',
'format.short.flat': 'Pantalla\nde cine',
'format.short.flatsbs': 'Cine 3D\nlado a lado',
'format.short.360mono': '360°',
'format.short.360eac': '360°\ncubemap',
'format.short.180mono': '180°',
'format.short.180sbs': '180° 3D\nlado a lado',
// The popup and the player's VR button.
'popup.button': 'Ver en VR (SteamVR)',
@@ -52,6 +56,14 @@
// The 2D Cinema settings tab.
'cinema.now': 'Ahora: {format}',
'cinema.nowNotFlat': 'Ahora: {format} · solo funcionan en la pantalla de cine (vídeos 2D)',
'cinema.follow': 'Seguir tu cabeza',
'cinema.followHint': 'En un video 2D, la pantalla y los paneles te alcanzan al girar',
'cinema.followDelay': 'Retraso al seguir',
'cinema.followSpeed': 'Velocidad al seguir',
'cinema.sooner': 'Antes',
'cinema.later': 'Después',
'cinema.slower': 'Más lento',
'cinema.faster': 'Más rápido',
'cinema.curve': 'Curva',
'cinema.curveAmount': 'Intensidad de la curva',
'cinema.distance': 'Distancia',
+17 -8
View File
@@ -3,13 +3,16 @@
// both worlds (the panel lists the formats; the renderer needs the projection). Which
// format a video is, is decided by the site's provider (detectFormat).
//
// There are no 3D formats: on desktop YouTube sends one eye only, even for 3D videos.
// 3D: most 3D videos reach the desktop as one eye only (shown as mono); some arrive with both
// eyes side by side in one frame, which '180sbs' (VR180) and 'flatsbs' (the cinema screen)
// give each eye its half of. Nothing in the site's data marks those, so they're picked by hand
// (docs/youtube.md, "Formats and VR180").
(function (ns) {
'use strict';
// The four formats. The ids are used everywhere: 'mono' = one picture for both eyes;
// 'eac' = equi-angular cubemap.
const IDS = Object.freeze(['flat', '360mono', '360eac', '180mono']);
// The six formats. The ids are used everywhere: 'mono' = one picture for both eyes;
// 'eac' = equi-angular cubemap; 'sbs' = 3D side by side (left eye's picture on the left).
const IDS = Object.freeze(['flat', 'flatsbs', '360mono', '360eac', '180mono', '180sbs']);
const DEFAULT = 'flat'; // when nothing is known about the video
const isFormat = id => IDS.includes(id);
@@ -23,14 +26,20 @@
// sites/youtube/page/provider/provider.js.)
// Horizontal span of the sphere, in radians (the cinema screen doesn't use a sphere).
const longitudeSpan = fmt => (fmt === '180mono' ? Math.PI : Math.PI * 2);
const longitudeSpan = fmt => (fmt === '180mono' || fmt === '180sbs' ? Math.PI : Math.PI * 2);
// Which part of the frame each eye sees, across: [scale, offset] for the left eye and the
// right eye ([1, 0] = the whole frame; side by side, the left and right halves).
const isSbs = fmt => fmt === '180sbs' || fmt === 'flatsbs';
const eyeSpans = fmt => (isSbs(fmt) ? { left: [0.5, 0], right: [0.5, 0.5] } : { left: [1, 0], right: [1, 0] });
// How the sphere maps the frame: 0 = one wrap-around picture (equirectangular),
// 1 = YouTube's equi-angular cubemap.
const projectionMode = fmt => (fmt === '360eac' ? 1 : 0);
// Flat videos go on the cinema screen; everything else on the video sphere.
const isFlat = fmt => fmt === 'flat';
// Flat videos (2D, or 3D side by side) go on the cinema screen; everything else on the
// video sphere.
const isFlat = fmt => fmt === 'flat' || fmt === 'flatsbs';
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, projectionMode, isFlat };
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, eyeSpans, isSbs, projectionMode, isFlat };
})(globalThis.__streamxr = globalThis.__streamxr || {});
+12
View File
@@ -16,13 +16,17 @@
// Video formats: the full names (the popup's picker) and short ones (the VR panel's
// Format button; \n = line break).
'format.flat': 'Cinema screen (regular video)',
'format.flatsbs': 'Cinema screen 3D side by side (both eyes in one frame)',
'format.360mono': '360°',
'format.360eac': '360° cubemap (YouTube "mesh")',
'format.180mono': '180° (VR180)',
'format.180sbs': '180° 3D side by side (both eyes in one frame)',
'format.short.flat': 'Cinema\nscreen',
'format.short.flatsbs': 'Cinema 3D\nside by side',
'format.short.360mono': '360°',
'format.short.360eac': '360°\ncubemap',
'format.short.180mono': '180°',
'format.short.180sbs': '180° 3D\nside by side',
// The popup on the video page, and the player's VR button.
'popup.button': 'Watch in VR (SteamVR)',
@@ -61,6 +65,14 @@
// The 2D Cinema settings tab.
'cinema.now': 'Now: {format}',
'cinema.nowNotFlat': 'Now: {format} · these work on the cinema screen (2D videos) only',
'cinema.follow': 'Follow your head',
'cinema.followHint': 'On a 2D video, the screen and panels catch up when you turn',
'cinema.followDelay': 'Follow delay',
'cinema.followSpeed': 'Follow speed',
'cinema.sooner': 'Sooner',
'cinema.later': 'Later',
'cinema.slower': 'Slower',
'cinema.faster': 'Faster',
'cinema.curve': 'Curve',
'cinema.curveAmount': 'Curve intensity',
'cinema.distance': 'Distance',
+12
View File
@@ -8,13 +8,17 @@
ns.i18n.add('es', {
// Video formats.
'format.flat': 'Pantalla de cine (vídeo normal)',
'format.flatsbs': 'Pantalla de cine 3D lado a lado (los dos ojos en un fotograma)',
'format.360mono': '360°',
'format.360eac': '360° cubemap (YouTube "mesh")',
'format.180mono': '180° (VR180)',
'format.180sbs': '180° 3D lado a lado (los dos ojos en un fotograma)',
'format.short.flat': 'Pantalla\nde cine',
'format.short.flatsbs': 'Cine 3D\nlado a lado',
'format.short.360mono': '360°',
'format.short.360eac': '360°\ncubemap',
'format.short.180mono': '180°',
'format.short.180sbs': '180° 3D\nlado a lado',
// The popup and the player's VR button.
'popup.button': 'Ver en VR (SteamVR)',
@@ -53,6 +57,14 @@
// The 2D Cinema settings tab.
'cinema.now': 'Ahora: {format}',
'cinema.nowNotFlat': 'Ahora: {format} · solo funcionan en la pantalla de cine (vídeos 2D)',
'cinema.follow': 'Seguir tu cabeza',
'cinema.followHint': 'En un video 2D, la pantalla y los paneles te alcanzan al girar',
'cinema.followDelay': 'Retraso al seguir',
'cinema.followSpeed': 'Velocidad al seguir',
'cinema.sooner': 'Antes',
'cinema.later': 'Después',
'cinema.slower': 'Más lento',
'cinema.faster': 'Más rápido',
'cinema.curve': 'Curva',
'cinema.curveAmount': 'Intensidad de la curva',
'cinema.distance': 'Distancia',