mirror of
https://github.com/FauxxYT/StreamXR.git
synced 2026-10-23 12:00:44 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf33215a60 | ||
|
|
649c689a03 | ||
|
|
19e04a3bc4 | ||
|
|
c8fcc6be5d | ||
|
|
1e1af79ecf | ||
|
|
36ef0d991f | ||
|
|
9176971309 | ||
|
|
cbffae17e7 | ||
|
|
ba21ef957a | ||
|
|
7dfa99e8f1 | ||
|
|
ac75bf2883 | ||
|
|
e15cfade16 | ||
|
|
e23c1bf40f | ||
|
|
c45ec45ea6 | ||
|
|
fa8441352c | ||
|
|
b554542559 | ||
|
|
ef539f9a39 | ||
|
|
fd80f53621 | ||
|
|
80785784c9 |
No files matched your search
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 || {});
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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 || {});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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 || {});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 || {});
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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 || {});
|
||||
@@ -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';
|
||||
|
||||
@@ -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 || {});
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || {});
|
||||
@@ -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 || {});
|
||||
@@ -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 || {});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 || {});
|
||||
@@ -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)); }
|
||||
|
||||
@@ -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 || {});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 || {});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 || {});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user