mirror of
https://github.com/FauxxYT/StreamXR.git
synced 2026-10-22 08:00:40 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa8441352c | ||
|
|
b554542559 | ||
|
|
ef539f9a39 | ||
|
|
fd80f53621 | ||
|
|
80785784c9 | ||
|
|
3a0c1f0d13 | ||
|
|
605972dfc4 | ||
|
|
8f5815dc1a | ||
|
|
3b0d26d082 | ||
|
|
200a8644c6 | ||
|
|
80934fcab4 | ||
|
|
e93b1a9a62 | ||
|
|
dec401c2be | ||
|
|
b49b3c79b3 | ||
|
|
5dfddfb746 |
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").
|
||||
@@ -109,13 +112,13 @@ for the code rules.
|
||||
`streamxr-*`, storage `streamxr.*` (storage.js moves old `streamvr.*` / `ytvr.*` values over).
|
||||
- No ES modules: each file is a plain script that adds to `globalThis.__streamxr`, and
|
||||
`manifest.json` holds the load order.
|
||||
- YouTube's panels' ids are `desc`, `comments`, `channel` (left of the Safe Zone), `recs`,
|
||||
`browse` (Explore), `you` (right), `search` (the search bar over the zone, its results in
|
||||
the middle) and `settings` (the pill's popup); the open side panels are remembered as
|
||||
`streamxr.leftPanel` / `streamxr.rightPanel`. Their controllers are in
|
||||
- YouTube's panels' ids are `desc`, `comments`, `channel` (left of the Safe Zone), `chat`
|
||||
(live streams only), `recs`, `browse` (Explore), `you` (right), `search` (the search bar over
|
||||
the zone, its results in the middle) and `settings` (the pill's popup); the open side panels
|
||||
are remembered as `streamxr.leftPanel` / `streamxr.rightPanel`. Their controllers are in
|
||||
`src/sites/youtube/page/provider/` (`description.js`, `comments-panel.js` (+ `comments.js`),
|
||||
`channel.js`, `search.js`, `browse.js` (Explore and You), `upnext.js`, `settings.js`),
|
||||
registered in `panels.js`; the controls panel's Captions and Save buttons are `extras.js`.
|
||||
`channel.js`, `search.js`, `browse.js` (Explore and You), `upnext.js`, `settings.js`, `live-chat.js`),
|
||||
registered in `panels.js`; the controls panel's Captions, Jump ahead and Save buttons are `extras.js`.
|
||||
- New YouTube behavior goes in the provider or a panel controller; a style or the shell
|
||||
never gets a YouTube field (add a generic option or slot instead).
|
||||
- Tunables: `vr-panel/config.js`, `controllers/config.js`, `SETTINGS` in `cinema-screen.js`,
|
||||
|
||||
+34
-11
@@ -104,13 +104,14 @@ src/core/ THE CORE (shell, part 1): the VR interface, site-
|
||||
kit.js generic card, images, badge registry, the M3 tabs (drawTabs, headerTabs), the M3 button, the hover ring, regionAt (hit tests; small controls reach out to 56 dp), createStrip (a row that slides sideways when the laser nears its end)
|
||||
grid.js tabs, pills, text field, card grid, paging (Catalog + Profile)
|
||||
thread.js a threaded comment list (Info's Comments, Profile's opened post)
|
||||
chat.js a live chat list (Info's 'chat' content): rows with inline pictures, cards, a pinned banner
|
||||
info.js / catalog.js / queue.js / profile.js / options.js
|
||||
orbiters-ui.js the side toolbars (upright M3E floating toolbars on the Safe Zone line), the pill (Settings) and the search bar
|
||||
orbiters-ui.js the side toolbars (upright M3E floating toolbars on the Safe Zone line), the pill (Settings) and the search bar; a side toolbar has a grab bar on its video side (moves its whole side)
|
||||
popups-ui.js the controls panel's floating menus and the dialog (raised in front: Android XR's popup and dialog levels)
|
||||
motion.js hover / press easing (M3E springs): one tracker per panel canvas; the opening springs (panels without bounce)
|
||||
laser-options.js the Laser settings tab: steadiness (the shell's own)
|
||||
appearance-options.js the Appearance settings tab: the colour theme, the glass look on / off and its tint, the panel size, vibration on / off (the shell's own)
|
||||
fade-options.js the Auto-hide settings tab: how long the controls panel, toolbars, search bar, controllers and lasers wait before hiding / fading
|
||||
fade-options.js the Auto-hide settings tab: how long the controls panel, toolbars, search bar, controllers and lasers wait before hiding / fading, and the "Auto-hide while paused" switch
|
||||
safe-zone.js the Safe Zone: where the layout goes, as shares of the headset's field of view (measured from the eyes' projections); its outline
|
||||
safe-zone-options.js the Safe zone settings tab: width, height, centre (the shell's own)
|
||||
keyboard-options.js the Keyboard settings tab: style and language (or yours), and the layout keyboard.js draws
|
||||
@@ -151,13 +152,14 @@ src/sites/youtube/ YOUTUBE
|
||||
youtube-channel.js any channel by id: header, tabs (sortable), playlists, shows ...
|
||||
youtube-home.js Home feed + topic chips, Explore feeds, menu, search suggestions
|
||||
youtube-subscriptions.js Subscriptions feed, Manage list, Subscribe/Unsubscribe, the bell
|
||||
youtube-chat.js a live stream's chat: its views, the chat page, polling, items -> plain messages
|
||||
provider/
|
||||
provider.js the provider (interface at its top); registers YouTube in sites.js
|
||||
panels.js the panel registry (each panel's zone, style, size, controller)
|
||||
extras.js the controls panel's Captions and Save buttons (the shell's "extras")
|
||||
extras.js the controls panel's Captions and Save buttons and Jump ahead at the seek bar's end (the shell's "extras": left, right, seek), premiumMark() (the Diamond)
|
||||
strings-en.js the YouTube panels' words in English (strings-<lang>.js: other languages)
|
||||
cards.js, badges.js YouTube items -> generic cards; the ✓ / ♪ badges
|
||||
upnext.js, browse.js (Explore and You), search.js, channel.js, description.js, comments-panel.js, settings.js
|
||||
upnext.js, browse.js (Explore and You), search.js, channel.js, description.js, comments-panel.js, settings.js, live-chat.js
|
||||
one controller per panel (comments.js: the comment threads, used by
|
||||
the Comments panel and a Channel post)
|
||||
ui/
|
||||
@@ -271,15 +273,36 @@ A site lists its panels (YouTube: `provider/panels.js`, which documents the inte
|
||||
size: 'wide' | 'tall' | 'standard', // vr-panel/config.js `sizes`
|
||||
overrides: { slots: { ... } }, // optional: the style's slots
|
||||
default: true, // optional: open the first time (on its side)
|
||||
available: st => bool, // optional (side panels): offered only while true
|
||||
// (asked every frame; its toolbar button hides meanwhile)
|
||||
autoOpen: true, // optional: opens whenever it becomes available (not
|
||||
// remembered); the panel it replaced comes back after
|
||||
stays: true, // optional: doesn't fade with the rest while you watch
|
||||
create(host) -> controller }
|
||||
```
|
||||
|
||||
- `host`: `{ actions, style, perPage, open(), close(), isOpen(), closeMenu(), confirm(d), typeInto(on) }`;
|
||||
`confirm(d)` asks in the shell's dialog (`{ title, text, confirm, cancel, danger }` -> a Promise
|
||||
YouTube's Live chat uses all three (`available` while a live stream with a chat plays):
|
||||
`vr-panel.js` `refreshAvailable` hides its toolbar button (`orbiters-ui.js` `setHidden`, the
|
||||
bar shrinking and laid out again) and keeps a `stays` panel drawn and updated while the panels
|
||||
are hidden.
|
||||
|
||||
- `host`: `{ actions, style, perPage, open(), close(), isOpen(), closeMenu(), openMenu(control), panel(id), confirm(d), typeInto(on, opts) }`
|
||||
(`opts.emojis`: the panel's own emojis, `[{ text, image, label }]`, first on the keyboard's emoji pages);
|
||||
`confirm(d)` asks in the shell's dialog (`{ title, text (up to four lines), confirm, cancel
|
||||
(null: no cancel button, a dialog that only informs), danger }` -> a Promise
|
||||
of true / false); the site's `onControl(id, panel, shell)` gets the same as `shell.confirm`.
|
||||
- Menus on side panels: `host.openMenu(control)` (e.g. `'action:bell'`) opens a floating menu
|
||||
above that control, 32 dp in front, like the controls panel's (the style needs `rectOf(id)`;
|
||||
Profile has it). While it's up, `vr-panel.js` asks the controller's `menu(control)` each frame
|
||||
(`{ items: [{ id, text, on, disabled, icons }], note, stay }`, `null` closes it); a choice
|
||||
arrives as `{ type: 'menu', value: item id, menu: control }`. The menu's id is
|
||||
`'p:<panel>|<control>'` and its items `'pi:<index>'` (`panelMenu`). `host.panel(id)` is another
|
||||
panel's controller (the You panel opens a channel in the Channel panel with it).
|
||||
- Typing: a panel with a text field calls `host.typeInto(true)` to get the keys (and
|
||||
`false` to give them back). While it's shown, each key reaches its controller as
|
||||
`{ type: 'key', value }`: a character, `Backspace`, `Enter` or `Escape`. The keys come
|
||||
`false` to give them back). One panel types at a time: when another asks, the one that had
|
||||
the keys is sent `Escape` (as for a click on nothing) and should stop typing. While it's shown, each key reaches its controller as
|
||||
`{ type: 'key', value }`: a character (or an emoji, or a panel emoji's `text`, from the
|
||||
keyboard's 😀 pages), `Backspace`, `Enter` or `Escape`. The keys come
|
||||
from the in-VR keyboard (`vr-panel/keyboard.js`; the `keyboard` binding swaps it with
|
||||
the controls panel in `vr-panel.js`: it hangs from the controls panel's top edge, sharing
|
||||
its pose) and from the computer's
|
||||
@@ -300,10 +323,10 @@ control's id there is `<panel id>|<style id>`.
|
||||
|
||||
| Style | YouTube panel | Draws |
|
||||
|---|---|---|
|
||||
| **Info** (`info.js`) | Description | header tabs; each tab one content kind: `text` (rich text, links), `paged` (rows in two columns: chapters), `rows` (scrolling, current row followed: transcript), `thread` (pills, count, rows with children and "show more": comments), `message` (optional loading indicator) |
|
||||
| **Info** (`info.js`) | Description, Comments, Live chat | header tabs; each tab one content kind: `text` (rich text, links), `paged` (rows in two columns: chapters), `rows` (scrolling, current row followed: transcript), `thread` (pills, count, rows with children and "show more": comments), `chat` (chat.js: a pinned banner, messages with inline pictures and coloured cards, following the newest or paused with a "jump to newest" button; optionally a message field with Send, reaction buttons and a Support button, an XP pill and a ranked Top fans list: live chat), `message` (optional loading indicator) |
|
||||
| **Catalog** (`catalog.js`) | Explore, You, Search | title, source tabs, filter pills (tabs and left-aligned pills slide when they don't fit: kit.js createStrip) or a note or a text field with its button (and a suggestions list dropping down from it), card grid, paging; trims its height to its cards |
|
||||
| **Queue** (`queue.js`) | Up Next | title or tabs, paging in the header, 3 x 2 cards, a "current" card |
|
||||
| **Profile** (`profile.js`) | Channel | banner, picture, name + badge, stats line, one action button (looks: primary / secondary / danger / busy), tabs (shrinking to fit beside the pills), pills, a search button (searching: a back button and a text field in place of the tabs), card grid, paging, or one featured card, or a scrolling body: a text `page` (paragraphs, then label / value facts), a `feed` of posts (picture, author, text, pictures, a card, a poll with result bars, buttons with icons and counts), or one opened `post` with a back button and its comments (thread.js) |
|
||||
| **Profile** (`profile.js`) | Channel | banner, picture, name + badge, stats line, one action button (looks: primary / secondary / danger / busy) and an optional round icon button beside it (the bell), tabs (shrinking to fit beside the pills), pills, a search button (searching: a back button and a text field in place of the tabs), card grid, paging, or one featured card, or a scrolling body: a text `page` (paragraphs, then label / value facts), a `feed` of posts (picture, author, text, pictures, a card, a poll with result bars, buttons with icons and counts), or one opened `post` with a back button and its comments (thread.js) |
|
||||
| **Options** (`options.js`) | Settings | tabs (with icons); up to 3 rows (name with its hint under it; at the right: a switch, a slider with a Default button and end captions, a ‹ value › stepper, or a button; faded) or a 4 x 4 choice grid with "More" paging |
|
||||
|
||||
Each style file's header lists its model and control ids. Every style returns
|
||||
@@ -312,7 +335,7 @@ Info and Profile add `scrollAt`, `maxScroll`, `shownScroll`, `pxPerV`, Options a
|
||||
|
||||
**Standard events:** a style's control ids are `<type>:<value>` and reach the controller
|
||||
as `{ type, value, fraction? }`. Types: `tab`, `pill`, `item`, `page`, `action`,
|
||||
`featured`, `post`, `vote`, `back`, `link`, `row`, `time`, `children`, `more`, `switch`, `slider`, `default`,
|
||||
`featured`, `post`, `vote`, `back`, `link`, `row`, `time`, `children`, `more`, `newest`, `react`, `send`, `support`, `fans`, `fansBack`, `switch`, `slider`, `default`,
|
||||
`choice`, `prev`, `next`, `button`, `field`, `clear`, `submit`, `suggest`, plus `scroll` (from dragging or the stick), `key` (typing), a slot's own ids, and the names a provider gives a post's buttons (YouTube: `postlike`, `postdislike`, `postcomments`).
|
||||
|
||||
**The generic card** (`kit.js`), used by every card grid and the hover card:
|
||||
|
||||
+329
-6
@@ -6,13 +6,332 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions are
|
||||
`manifest.json` version. All of these were developed in October 2026. Technical notes for
|
||||
developers are in [ARCHITECTURE.md](ARCHITECTURE.md) and [docs/](docs/).
|
||||
|
||||
## Unreleased
|
||||
## 0.93.3
|
||||
|
||||
### Documentation
|
||||
- The README's "as soon as it's possible" roadmap and "What you need" table: **native Steam
|
||||
Frame support**, StreamXR running in the Frame's own Chromium browser (no PC) once that
|
||||
browser supports WebXR. Also in `docs/hardware.md` and `docs/whats-left.md`.
|
||||
- The README's links to "What about Netflix, MAX and the others?" work again.
|
||||
### Changed
|
||||
- The cinema screen's and 3D Cinema's icons are Material Symbols' **2d_2** and **3d_2** (the
|
||||
plain "2D" / "3D" lettering).
|
||||
|
||||
## 0.93.2
|
||||
|
||||
### Changed
|
||||
- **The Format button** always says **Format**; its icon shows the chosen format (the format's
|
||||
name is in the menu).
|
||||
- The cinema screen's icon is Material Symbols' **2d** (3D Cinema keeps `3d`).
|
||||
- The Format menu names Cinema screen 3D side by side "Cinema 3D / side by side" again.
|
||||
|
||||
## 0.93.1
|
||||
|
||||
### Changed
|
||||
- **The Format menu** shows each format's icon on the left of its row (as the Quality menu
|
||||
does), the same icons as the Format button.
|
||||
- Cinema screen 3D side by side is called **3D Cinema** on the Format button (the longer name
|
||||
was too tight).
|
||||
|
||||
## 0.93.0
|
||||
|
||||
### Added
|
||||
- **Cinema screen 3D side by side:** a new format for regular (not VR180) 3D videos that put
|
||||
both eyes in one frame: the cinema screen gives each eye its half, in 3D, with the `3d` icon
|
||||
on the Format button (180° 3D side by side keeps its own). The screen takes one eye's shape:
|
||||
a 16:9 frame with each eye squeezed into half ("half side by side", the usual upload) shows
|
||||
at 16:9, a frame of two full pictures (wider than 2.5:1) at half its width. The glow and the
|
||||
glass behind the panels show each eye its own half too. Picked by hand in the Format menu.
|
||||
|
||||
## 0.92.3
|
||||
|
||||
### Fixed
|
||||
- **Jump ahead** now follows YouTube's own Jump ahead pill: the button at the seek bar's end
|
||||
shows only while YouTube offers it (so not on videos without it, live streams or their
|
||||
replays), and clicking it clicks YouTube's pill, which jumps (the keyboard shortcut it sent
|
||||
before did nothing from VR). Greyed out while paused.
|
||||
|
||||
## 0.92.2
|
||||
|
||||
### Fixed
|
||||
- **Videos didn't open from the Channel panel or History** while YouTube's page was on a
|
||||
channel page (VR started there, nothing playing yet): YouTube ignored the in-page
|
||||
navigation there. It's now sent from inside the page, which works on channel pages, Home
|
||||
and the watch page alike.
|
||||
- **Floating menus:** the ticked row covered the pointing ring of the row next to it; the
|
||||
rings are drawn after all the rows now.
|
||||
- **You → Channels sort pills** still didn't show: the sort choices sit deeper in YouTube's
|
||||
answer than the reader looked; it searches all the way down now.
|
||||
|
||||
## 0.92.1
|
||||
|
||||
### Changed
|
||||
- **Jump ahead** moved to the end of the seek bar, where Skip ad shows during ads (the
|
||||
maintainer's choice): a button with the Diamond, "Jump ahead" and fast-forward; the seek bar
|
||||
shortens for it. It's hidden on live streams and greyed out while paused. (A generic slot:
|
||||
a site's `extras.seek` button, panel-ui.js.)
|
||||
|
||||
## 0.92.0
|
||||
|
||||
### Added
|
||||
- **Jump ahead** (YouTube Premium) on the controls panel, right after +10 s: fast-forward over
|
||||
the **Diamond**. It jumps to the point most viewers skip to, as YouTube's own Jump ahead
|
||||
does (it sends YouTube's desktop shortcut, Ctrl + →, to the player). Usable while a video
|
||||
plays; not on live streams or during an ad. Without Premium it's greyed out with the purple
|
||||
Diamond. On videos YouTube doesn't offer Jump ahead for (Shorts, music, movies...) it does
|
||||
nothing.
|
||||
|
||||
### Changed
|
||||
- The controls panel's buttons narrow a little when the site adds more buttons than fit the
|
||||
row, so it keeps its margins (one more site button on the right, Jump ahead, made it so).
|
||||
|
||||
## 0.91.0
|
||||
|
||||
### Added
|
||||
- **1080p Premium** in the Quality menu (YouTube Premium's enhanced-bitrate 1080p, on videos
|
||||
uploaded at 1080p): its own row just above the plain 1080p, marked with the **Diamond** in
|
||||
YouTube red. Without Premium the row shows a purple Diamond and is greyed out (as YouTube's
|
||||
own menu upsells it). While it plays, the Quality button shows Full HD with the Diamond.
|
||||
- For other sites (ARCHITECTURE.md): an icon can carry its own colour (`{ name, color,
|
||||
filled }`: symbols.js `drawIcon`), in menu rows and on the Quality button; a quality level
|
||||
can be `disabled`.
|
||||
|
||||
## 0.90.3
|
||||
|
||||
### Fixed
|
||||
- **You → Channels:** the sort pills (Most relevant, New activity, A-Z) and the section flags
|
||||
(e.g. **Purchased**) never showed: they're read now from where YouTube's page really keeps
|
||||
them (the sort choices as list items that reload the list, the sections as titled shelves).
|
||||
|
||||
## 0.90.2
|
||||
|
||||
### Removed
|
||||
- **The Posts pill in You → Subscriptions:** desktop YouTube's Subscriptions feed doesn't mix
|
||||
community posts in (checked signed in), so it always came up empty. Posts are still on each
|
||||
channel's Posts tab.
|
||||
|
||||
## 0.90.1
|
||||
|
||||
### Added
|
||||
- **Channels sort pills:** You → Channels offers YouTube's own sort choices for the list (Most
|
||||
relevant, New activity, A-Z), the one it's sorted by chosen. Channels YouTube lists in a
|
||||
section of their own (e.g. **Purchased**, a channel you're a member of) carry that
|
||||
section's name as a flag.
|
||||
|
||||
### Fixed
|
||||
- **3D side by side:** the glass panels showed the left eye's picture behind them in both
|
||||
eyes; each eye now sees its own half behind the glass too.
|
||||
- **Floating menus** (the bell's, Format, Quality...) cut off the pointing ring round their
|
||||
first and last rows.
|
||||
- The Channels list could stop after its first page when YouTube splits it into sections.
|
||||
|
||||
## 0.90.0
|
||||
|
||||
### Added
|
||||
- **Your channels** (You → **Channels**, signed in): the channels you subscribe to, A to Z, with
|
||||
their round pictures, handle · subscribers and their notification setting. Click one to
|
||||
open it in the Channel panel.
|
||||
- **The notification bell** on the Channel panel: once you're subscribed, a round bell button
|
||||
beside Subscribed opens a small menu with YouTube's choices (All, Personalized, None), the
|
||||
current one ticked; its icon shows which is on. The tick moves only when YouTube confirms.
|
||||
- **Posts from your subscriptions:** You → Subscriptions has two pills, **Videos** (the feed)
|
||||
and **Posts** (the community posts mixed into it). A post that shares a video plays it;
|
||||
any other opens its channel's Posts tab.
|
||||
- For other sites' panels (ARCHITECTURE.md): side panels can open a floating menu from one of
|
||||
their controls (`host.openMenu(control)`, the controller's `menu(control)`, choices as
|
||||
`{ type: 'menu' }` events) and reach another panel's controller (`host.panel(id)`); the
|
||||
Profile style has a round icon button beside its action button (`iconAction`) and
|
||||
`rectOf(id)`; a card can show a round picture (`fit: 'round'`). New icons: `diamond`,
|
||||
`fast_forward`, `notifications_off`.
|
||||
|
||||
## 0.89.0
|
||||
|
||||
### Added
|
||||
- **3D side-by-side videos:** a new format, **180° 3D side by side**, in the Format menu (and
|
||||
the video page's format picker). Some VR180 videos reach the browser with both eyes in one
|
||||
frame (left eye's picture on the left), which used to be stretched over the half sphere as
|
||||
one picture; this format gives each eye its half, in 3D. YouTube's data doesn't mark these
|
||||
videos, so pick it by hand when a VR180 video shows two pictures side by side. (Most 3D
|
||||
videos reach desktop browsers as one eye only and still play flat:
|
||||
[decisions/0002](docs/decisions/0002-site-player-only.md).)
|
||||
- For other renderers: the glass backdrop's `uvX` option (the part of the frame a sphere
|
||||
backdrop covers, across), `formats.eyeSpans(fmt)`.
|
||||
|
||||
## 0.88.1
|
||||
|
||||
### Changed
|
||||
- The Auto-hide tab's new switch is called **"Auto-hide while paused"** (was "Hide while paused"), so
|
||||
it says what it does.
|
||||
|
||||
## 0.88.0
|
||||
|
||||
### Added
|
||||
- **Pull the trigger on empty space to hide everything at once** (suggested by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)): the controls panel, the side
|
||||
toolbars, the search bar and the lasers go away without waiting for auto-hide (paused too).
|
||||
A press that closes a menu, a dialog or typing still only does that; the next one hides.
|
||||
Another pull, or pointing at a panel, brings them back. Panels set to stay up (the live
|
||||
chat) stay.
|
||||
- **Settings → Auto-hide → "Hide while paused"** (suggested by u/TehSuperFruitSnack; off by default, remembered): while on, the
|
||||
auto-hide waits also run with the video paused, so you can pause and take in a 180° / 360°
|
||||
scene with nothing in the way. The waits themselves are unchanged.
|
||||
|
||||
## 0.87.1
|
||||
|
||||
### Fixed
|
||||
- Opening a video whose chat YouTube's page hasn't loaded yet (e.g. going from a stream without a
|
||||
chat replay to one with) showed "Couldn't load the chat" until you switched tabs. The chat now
|
||||
shows the loading indicator and tries again on its own (every 3 s, up to five times) before
|
||||
giving up.
|
||||
|
||||
## 0.87.0
|
||||
|
||||
### Added
|
||||
- **Chat replays:** on a finished live stream that kept its chat, the Live chat panel plays the
|
||||
recorded chat along with the video (tabs: YouTube's "Top chat replay" / "Live chat replay").
|
||||
Each message appears when the video reaches the moment it was sent, so pausing stops the
|
||||
chat, and jumping back or ahead starts it again at the new place with the last minute of
|
||||
chat shown at once. It opens by itself as the live chat does. No typing, reactions or Top
|
||||
fans in replays, as on YouTube.
|
||||
|
||||
## 0.86.0
|
||||
|
||||
### Added
|
||||
- **Settings → Safe zone → "Recenter resets panels"** (off by default, remembered): while on,
|
||||
recentering also puts the panels you moved back in their places, as before 0.84.0. The global
|
||||
reset turns it off.
|
||||
|
||||
### Changed
|
||||
- The left D-pad's icon (show / hide the panels) is Material Symbols' **Bottom Panel Close**
|
||||
while the panels are up and **Bottom Panel Open** while they're hidden (was a single "call to
|
||||
action" icon).
|
||||
|
||||
## 0.85.0
|
||||
|
||||
### Changed
|
||||
- **The side panels' move handle is a grab bar** beside each side's toolbar, on the video side:
|
||||
an upright pill with a drag handle, like the keyboard's and the controls panel's bars (so
|
||||
everything you can move is grabbed the same way), with a laser zone wider than it looks.
|
||||
Grip it to move the whole side. It replaces the Move button at the toolbar's end; the
|
||||
toolbars stand a little further out to make room.
|
||||
|
||||
## 0.84.2
|
||||
|
||||
### Changed
|
||||
- **The grip's icon shows a hand** (Material Symbols' Back Hand) instead of recenter whenever the
|
||||
grip would grab rather than recenter: pointing at (or holding) a move bar or the side
|
||||
toolbars' Move handle, the keyboard's bar or resize corners, or the cinema screen's bar and
|
||||
corners.
|
||||
|
||||
## 0.84.1
|
||||
|
||||
### Changed
|
||||
- **The side panels' move handle is on their toolbar** now: a **Move** button (four arrows) at
|
||||
the end of each side's toolbar, after a small divider; pointing at it grows its name like the
|
||||
others, and gripping it moves the whole side (the point you hold follows your hand). It
|
||||
replaces the bar under the panel, which was hard to find.
|
||||
|
||||
## 0.84.0
|
||||
|
||||
### Changed
|
||||
- **Recenter keeps what you moved:** recentering turns the layout to where you're looking and
|
||||
brings your moved panels along, each where you put it relative to you: the controls panel
|
||||
(with the keyboard and Settings) and any side moved by its grab bar. It used to put them all
|
||||
back. Changing the Safe Zone or the panel size keeps them too; only the global reset (right
|
||||
stick held 3 s) puts them back in their places.
|
||||
|
||||
## 0.83.1
|
||||
|
||||
### Fixed
|
||||
- Pointing at the **live chat while the other panels are hidden** (it stays up while you watch):
|
||||
the stick showed its seek icon instead of the scroll icon, and pushing it scrolled the chat
|
||||
*and* jumped the video ±10 s. The stick now belongs to a panel that stays, as to any shown
|
||||
panel: scroll icon, scrolling only.
|
||||
|
||||
## 0.83.0
|
||||
|
||||
### Added
|
||||
- **Move the side panels:** a grab bar under each side's open panel (left and right). Grip it
|
||||
and move: the panel follows your hand and turns to face you, and its whole side comes along
|
||||
(the toolbar, and every other panel on that side opens there too). Recenter, a Safe Zone
|
||||
change or a new panel size puts the sides back. (`vr-panel.js` keeps a move per side;
|
||||
`orbiters-ui.js` `createGrabBar`; `math3d.js` `invertRigid`.)
|
||||
|
||||
## 0.82.1
|
||||
|
||||
### Fixed
|
||||
- **Two text cursors:** clicking the search bar while typing in the live chat (or the other way
|
||||
round) left a cursor in the field you'd left, and a click on nothing then only cleared one.
|
||||
Now the field you leave stops typing as soon as another one starts (the shell sends it
|
||||
Escape), and the keyboard stays up through the switch.
|
||||
|
||||
## 0.82.0
|
||||
|
||||
### Added
|
||||
- **Top fans** in the live chat: your **XP** in a 👑 pill at the right end of the chat's tabs
|
||||
(kept up to date as you chat); clicking it opens the **Top fans** list in the panel: rank,
|
||||
picture, name and XP, the top three with their badges, and your own row at the bottom with
|
||||
YouTube's line about it (refreshed every 30 seconds while open; ‹ goes back to the chat). The
|
||||
top three's **"#1" / "#2" / "#3" pills** now show before their names in the chat.
|
||||
|
||||
## 0.81.1
|
||||
|
||||
### Fixed
|
||||
- The **Support on desktop** dialog cut its message off ("…") before the end: dialogs now
|
||||
show up to four lines of text, the card growing to fit (short ones, like "Unsubscribe?",
|
||||
stay as they were).
|
||||
|
||||
## 0.81.0
|
||||
|
||||
### Added
|
||||
- **Typing in the live chat:** a message field with a **Send** button above the reaction
|
||||
buttons. Clicking the field brings up the VR keyboard; Enter or Send posts the message (it
|
||||
then appears in the chat), Escape or a click on nothing stops typing. YouTube's limit is kept
|
||||
(200 characters, an emoji counting as 10). Where you can't chat, YouTube's reason shows
|
||||
instead of the field.
|
||||
- **Emojis on the VR keyboard:** a 😀 key opens pages of emojis (‹ › between pages), with the
|
||||
panel's own first: in the live chat, the channel's member emojis, shown as pictures in the
|
||||
field and sent as emojis.
|
||||
- **Support on desktop** (live chat): a button beside the reactions opening a dialog with what
|
||||
the stream offers (Super Chat, gifts, memberships…) and where to pay for it: YouTube's own
|
||||
window, with the $ button under the chat.
|
||||
- For the shell: `typeInto(on, { emojis })`, the dialog's text on two lines and `cancel: null`
|
||||
(an information dialog), the Info style's chat `input` and `support`.
|
||||
|
||||
## 0.80.0
|
||||
|
||||
### Added
|
||||
- **Live chat reactions:** the stream's reaction buttons (❤ 😄 🎉) along the bottom of the Live
|
||||
chat panel; clicking one sends it to YouTube (at most one every half second) and lights the
|
||||
button a moment. Shown only when the stream offers reactions (signed in). Other viewers'
|
||||
floating reactions aren't shown yet. (Info style's `chat` content: `reactions`.)
|
||||
|
||||
## 0.79.1
|
||||
|
||||
### Fixed
|
||||
- **Live chat** said "Couldn't load the chat" on both tabs: the watch page's view tokens load an
|
||||
empty chat page. The views' tokens now come from the chat frame beside the stream (the ones
|
||||
YouTube itself uses), or, when the page shows no chat, from the pop-out chat page.
|
||||
|
||||
## 0.79.0
|
||||
|
||||
### Added
|
||||
- **Live chat** (reading): on a live stream, a **Live chat** panel opens on the right (its
|
||||
button, at the top of the right toolbar, shows only on live streams). It **stays up while
|
||||
you watch** when everything else fades. Tabs for YouTube's **Top chat** and **Live chat**
|
||||
views (the one you pick is remembered); new messages every 2 seconds. Messages show the
|
||||
author's picture and name (the owner on a yellow chip, moderators in blue with a wrench,
|
||||
members in green with their badge), custom and standard emojis inline, **Super Chats**,
|
||||
**Super Stickers** and **memberships** as coloured cards, YouTube's welcome notice, and the
|
||||
**pinned message** in a banner at the top. Scrolling back pauses the list ("Chat paused", as on
|
||||
YouTube) and a button shows how many new messages are waiting; it, or scrolling to the end,
|
||||
goes back to the newest. Deleted messages disappear. YouTube's AI "Chat summary" banner is
|
||||
left out. Not yet: chat replays (after a stream), reactions, typing.
|
||||
- For other sites' panels (generic registry options, ARCHITECTURE.md): `available` (a side
|
||||
panel offered only some of the time: its toolbar button hides meanwhile), `autoOpen` (opens
|
||||
whenever it becomes available, the panel it replaced coming back after) and `stays` (doesn't
|
||||
fade with the rest); the Info style's `chat` content (`styles/chat.js`).
|
||||
|
||||
## 0.78.10
|
||||
|
||||
### Changed
|
||||
- The **Quality menu** shows each quality's icons on the left (4K, 2K, Full HD, HD, below-HD,
|
||||
plus 60 fps where it applies), the same icons as the Quality button; the tick moves to the
|
||||
right end. (A generic menu option, `icons` on an item, popups-ui.js.)
|
||||
|
||||
## 0.78.9
|
||||
|
||||
@@ -26,6 +345,10 @@ developers are in [ARCHITECTURE.md](ARCHITECTURE.md) and [docs/](docs/).
|
||||
**Streaming Status** → your PC → **Advanced** → **Stream VR without game**, then open Chrome.
|
||||
Also in the troubleshooting table, the "What you need" table, the question issue form and
|
||||
`docs/hardware.md`.
|
||||
- The README's "as soon as it's possible" roadmap and "What you need" table: **native Steam
|
||||
Frame support**, StreamXR running in the Frame's own Chromium browser (no PC) once that
|
||||
browser supports WebXR. Also in `docs/hardware.md` and `docs/whats-left.md`.
|
||||
- The README's links to "What about Netflix, MAX and the others?" work again.
|
||||
|
||||
## 0.78.8
|
||||
|
||||
|
||||
@@ -4,18 +4,39 @@ experience: 360° and VR180 videos wrap around you, regular videos play on a gia
|
||||
screen, and you can browse, search, subscribe, like, save and read comments without ever
|
||||
taking your headset off. 🎬✨
|
||||
|
||||
**Works with:**<br> 🖥️ Windows · 🌐 Chrome · 🎮 SteamVR · 🥽 Valve Steam Frame · ([full requirements](#-what-you-need))<br>
|
||||
**Works with:**<br> 🖥️ Windows · 🌐 Chrome · 🎮 SteamVR · 🥽 Valve Steam Frame · 🥽 Meta Quest 2 (reported by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)) · ([full requirements](#-what-you-need))<br>
|
||||
**Jump to:** [Get started](#-get-started-in-a-minute) ·
|
||||
[Quick troubleshooting](#-quick-troubleshooting) · [Controls](#-controls) ·
|
||||
[Your privacy](#-your-privacy) · [Roadmap](#-roadmap) · [How you can help](#-how-you-can-help)
|
||||
[Your privacy](#-your-privacy) · [What's new](#-whats-new-in-v0933) · [Roadmap](#-roadmap) · [How you can help](#-how-you-can-help)
|
||||
|
||||
> **Early access** (version 0.78). Free and open source, forever. It already works every day; it's
|
||||
> **Early access** (version 0.93). Free and open source, forever. It already works every day; it's
|
||||
> still growing fast, and your feedback shapes what comes next.
|
||||
>
|
||||
> StreamXR is vibe coded by one guy (me!), and the only headset I've got is the Steam Frame.
|
||||
> Bug fixes, updates and support for more headsets and sites are coming, so please be
|
||||
> patient with me. 😭🙏
|
||||
|
||||
## ✨ What's new in v0.93.3
|
||||
|
||||
The latest release, in short (every version's details are in the [changelog](CHANGELOG.md)):
|
||||
|
||||
- 🕶️ **3D side-by-side videos:** some 3D videos reach your browser with both eyes in one frame.
|
||||
Pick **180° 3D side by side** (VR180) or **Cinema 3D side by side** (regular 3D videos on the
|
||||
cinema screen) in the Format menu, and each eye gets its own half, in real 3D. Thanks to
|
||||
[u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack) for spotting them!
|
||||
- 💎 **YouTube Premium in VR:** **1080p Premium** (the enhanced bitrate) in the Quality menu, and
|
||||
**Jump ahead** at the end of the seek bar whenever YouTube offers it, both marked with the
|
||||
Diamond.
|
||||
- 👥 **Your channels:** You → **Channels** lists the channels you subscribe to, sorted your way
|
||||
(Most relevant, New activity, A-Z). Click one to open it in the Channel panel.
|
||||
- 🔔 **The notification bell:** on a channel you subscribe to, choose All, Personalized or None
|
||||
right from the Channel panel.
|
||||
- 🎚️ **A tidier Format menu:** every format with its own icon, and the Format button shows the
|
||||
one you picked.
|
||||
- 🩹 **Fixes:** videos open from the Channel panel and History even when VR started on a
|
||||
channel page; floating menus no longer clip the pointing ring; the Channels list loads all
|
||||
the way down.
|
||||
|
||||
## 💡 Why I made this
|
||||
|
||||
Two reasons:
|
||||
@@ -82,7 +103,8 @@ more likely they are to open the door. **If and when they do, I'll make it work
|
||||
## 🌟 What you get
|
||||
### 🎥 Every kind of video, the way it was meant to be seen
|
||||
- **360° and VR180** wrap around you (YouTube's cubemap "mesh" format included); the format
|
||||
is detected automatically and can be changed any time.
|
||||
is detected automatically and can be changed any time. **3D side-by-side** videos play in
|
||||
real 3D, as VR180 or on the cinema screen (pick it in the Format menu).
|
||||
- **Regular videos** play on a **cinema screen** you can **curve**, **resize**, **move** and
|
||||
**push away**, with **rounded corners** and an optional **ambient glow** that spills the
|
||||
picture's colors around it, like a TV with backlighting. 🌈
|
||||
@@ -97,7 +119,8 @@ more likely they are to open the door. **If and when they do, I'll make it work
|
||||
- Title, views, date and the channel, with **Subscribe**, **Like** and **Dislike**.
|
||||
- A **seek bar** with chapter marks, a **preview frame**, YouTube's **Most Replayed** curve
|
||||
and how much has **loaded**.
|
||||
- **Quality** (8K, 4K, 60 fps...), **speed**, **volume**, **format**, **recenter** and exit.
|
||||
- **Quality** (8K, 4K, 60 fps, and **1080p Premium** 💎 with Premium), **speed**, **volume**,
|
||||
**format**, **recenter** and exit; **Jump ahead** 💎 at the seek bar's end when YouTube offers it.
|
||||
- **💬 Captions** in any language the video has, drawn right in VR.
|
||||
- **🔖 Save** to Watch later or any of your playlists.
|
||||
- Playlists get **Previous / Next** with a preview card; live streams get a **LIVE** label
|
||||
@@ -111,8 +134,14 @@ glassy toolbars on each side:
|
||||
and **Channel** (videos, Shorts, live streams, playlists, posts, About, Subscribe).
|
||||
- 🔎 **Search** above the video, with suggestions as you type on the **VR keyboard**.
|
||||
- ➡️ **What to watch:** **Up Next**, **Explore** (your Home feed with all its topic chips,
|
||||
"Immersive videos" included, plus News) and **You** (Subscriptions, Your channel,
|
||||
"Immersive videos" included, plus News) and **You** (Subscriptions, your Channels with their
|
||||
notification bells, Your channel,
|
||||
History, Playlists, Watch later, Liked videos).
|
||||
- 💬 **Live chat** on live streams (and **chat replays** on finished ones, playing along with the
|
||||
video): it opens by itself and stays up while you watch. Read it with emojis, badges, Super
|
||||
Chats and the pinned message; send reactions and type messages on the VR keyboard (with your
|
||||
channel's member emojis); see your **XP** and the **Top fans**. Every side can be moved
|
||||
by the grab bar beside its toolbar.
|
||||
|
||||
### 🎨 Make it yours (Settings)
|
||||
- Looks: **YouTube** or **Material 3** colors, **glass** panels with an adjustable tint, the
|
||||
@@ -177,7 +206,7 @@ The full design story is in [docs/Materials-Redesign.md](docs/Materials-Redesign
|
||||
|---|---|---|
|
||||
| 🖥️ **Computer** | **Windows 10 / 11** | Tested on Windows 11. macOS and Linux aren't supported (Chrome's WebXR needs Windows + SteamVR). |
|
||||
| 🌐 **Browser** | **Google Chrome** | Tested. Other Chromium browsers (Edge, Brave) should work but haven't been tried. Firefox can't yet: desktop Firefox has no WebXR (a 🦊 Firefox version will come once it does). |
|
||||
| 🥽 **Headset** | **Valve Steam Frame** | Tested and tuned for it, controller models and all, streamed from your PC. (Running in the Frame's own browser, no PC needed, comes once it supports WebXR: see the [roadmap](#-roadmap).) Other SteamVR headsets may well play video, but they're untested and their buttons may be mapped differently ([help add yours](#-adding-a-headset)). |
|
||||
| 🥽 **Headset** | **Valve Steam Frame**<br>**Meta Quest 2** (reported by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)) | **Steam Frame:** tested and tuned for it, controller models and all, streamed from your PC. (Running in the Frame's own browser, no PC needed, comes once it supports WebXR: see the [roadmap](#-roadmap).)<br>**Quest 2:** reported working through SteamVR, 8K videos included. Its controllers show as Steam Frame models.<br>Other SteamVR headsets may well work too; their buttons may be mapped differently ([help add yours](#-adding-a-headset)). |
|
||||
| 🎮 **VR software** | **SteamVR** | Running **before** you open Chrome, and set as your OpenXR runtime. On the Steam Frame, stream it with **Stream VR without game** ([how](#-get-started-in-a-minute)). |
|
||||
| 📺 **Site** | **YouTube** | Signed in or out. With YouTube Premium, even better (no ads). |
|
||||
| 🧩 **Optional** | **Skip Helper** | A tiny second extension that lets you skip ads from inside VR ([why it's separate](#-skipping-ads)). |
|
||||
@@ -227,11 +256,11 @@ Still stuck? See [Something not working?](#-something-not-working) for how to re
|
||||
|
||||
| Input | Does |
|
||||
|---|---|
|
||||
| Trigger | click the panels with the laser; hold on the seek bar to scrub; drag a scrolling area; brings the panels (and lasers) back when they're hidden |
|
||||
| Trigger | click the panels with the laser; hold on the seek bar to scrub; drag a scrolling area; on empty space, hides the panels, toolbars and search bar at once (another pull brings them back, with the lasers) |
|
||||
| A (right) | play / pause |
|
||||
| B (right) | swap the controls panel for the keyboard (same place), and back: click its keys with the laser to type into a text box (Search); the bar under it moves it (and the controls panel with it), its bottom corners resize it |
|
||||
| Left D-pad | show / hide the panels |
|
||||
| Grip | recenter, or grab: the bar under the panel moves it; on the cinema screen the corner arcs resize it and the bar under it moves it (remembered) |
|
||||
| Grip | recenter, or grab (its icon turns into a hand when it will grab): the bar under the panel moves it (the bar beside a side's toolbar moves that whole side; recentering keeps what you moved, unless you turn on Settings → Safe zone → "Recenter resets panels", and holding the right stick for 3 s puts it all back); on the cinema screen the corner arcs resize it and the bar under it moves it (remembered) |
|
||||
| Stick left / right | ±10 s. On a panel with page arrows: previous / next page (the stick's icon changes to ◀▯▶) |
|
||||
| Stick up / down | on a scrolling area (Description, Transcript, Comments, a long menu): scroll (icon ▲≡▼) |
|
||||
| Right stick click, held 3 s | reset all settings to their defaults and recenter (the stick's icon counts down 3-2-1 with a ring; let go to cancel) |
|
||||
@@ -286,15 +315,18 @@ what bugs you most):
|
||||
- **No controller vibration.** Chrome doesn't give the Steam Frame controllers a way to
|
||||
vibrate yet, so Settings → Appearance → Vibration is greyed out.
|
||||
- **Only Steam Frame controller models.** Other controllers are drawn as Steam Frame
|
||||
controllers, and other headsets are untested. **Got 3D models of your controllers?** 🧊
|
||||
controllers. Besides the Steam Frame, only the Quest 2 has been reported working (by
|
||||
u/TehSuperFruitSnack); other headsets are untested. **Got 3D models of your controllers?** 🧊
|
||||
Share them in a **Headset / controller compatibility** issue (with where they came from and
|
||||
their license) and I'll integrate them once I start accepting pull requests. They need a
|
||||
license that lets them be shared and changed in a free project, with credit (for example
|
||||
CC BY, CC BY-SA, CC BY-NC-SA or CC0), like the Steam Frame models' (CC BY-NC-SA 4.0).
|
||||
|
||||
**🎬 Video**
|
||||
- **No 3D (stereo) videos.** On desktop, YouTube only sends one eye's picture, so 3D videos
|
||||
play flat.
|
||||
- **Most 3D (stereo) videos play flat.** On desktop, YouTube sends only one eye's picture for
|
||||
most 3D videos (and caps some VR180 videos at 4K), while the Meta Quest app gets both eyes.
|
||||
StreamXR shows what YouTube's own player plays and won't bypass it: [why](docs/decisions/0002-site-player-only.md).
|
||||
The 3D videos that do arrive with both eyes side by side play in 3D (Format menu).
|
||||
- **Shorts are very tall** on the cinema screen: they keep their vertical shape at the
|
||||
screen's full width. Honestly, I left this in because I think it's funny, but I'll fix it
|
||||
eventually lol 😂
|
||||
@@ -364,18 +396,28 @@ which is often all that's needed. 📋
|
||||
No dates, just what I'll work on after release, in this order:
|
||||
|
||||
1. 🐛 **Immediate bug fixes:** whatever the first wave of users runs into.
|
||||
2. 🏪 **Chrome Web Store release:** install StreamXR in one click and get updates
|
||||
automatically, no more downloading new versions by hand. Still free, of course.
|
||||
3. 💎 **YouTube Premium features:** Premium quality levels, Add to queue and more.
|
||||
4. 🧭 **Explore page tabs:** Live, Gaming, Music, Podcasts and the rest of the "Coming Soon"
|
||||
2. 📋 **Add to queue:** line up videos to watch next from any panel, without leaving VR.
|
||||
3. 🧭 **Explore page tabs:** Live, Gaming, Music, Podcasts and the rest of the "Coming Soon"
|
||||
tabs.
|
||||
5. 💬 **Live Chat:** See the chat of a Live Stream while watching
|
||||
6. 💬 **Commenting and Live Chat in-headset:** write comments, replies, and type in a live chat with the VR keyboard.
|
||||
7. 🟣 **Twitch.tv support:** the first site after YouTube.
|
||||
8. 🔐 **Groundwork for YouTube Movies, Netflix, HBO Max, Disney+ and other protected videos:** the
|
||||
infrastructure to play them in VR, ready for the day they allow it
|
||||
([why they don't yet](#-what-about-netflix-max-and-the-others)).
|
||||
9. ✨ **Whatever else I can think of** (and whatever you ask for!).
|
||||
4. ✅ **Live Chat:** done (v0.88.1): read, react and type in a live stream's chat.
|
||||
5. ✅ **YouTube Premium:** done (v0.93.3): 1080p Premium and Jump ahead, marked with the Diamond.
|
||||
6. 💬 **Commenting in-headset:** write comments and replies with the VR keyboard (typing in a live
|
||||
chat is already in).
|
||||
7. 🏪 **Chrome Web Store release, StreamXR 1.0:** once the YouTube features are all in, install
|
||||
StreamXR in one click and get updates automatically, no more downloading new versions by
|
||||
hand. Still free, of course.
|
||||
8. 🟣 **Twitch.tv support:** the first site after YouTube.
|
||||
9. 🪼 **Jellyfin** and 🟠 **Plex support:** your own media server's movies and shows in VR, on the
|
||||
cinema screen. Your own files aren't copy-protected, so they will be able to play.
|
||||
10. 🎵 **Music streaming**, starting with **Spotify:** listen while you're in VR, with the player's
|
||||
controls, album art and queue around you.
|
||||
11. 🌐 **Barebones mode (experimental):** any video on any website on the cinema screen, with the
|
||||
basic controls (play, seek, volume, format), for sites without full support. Some sites
|
||||
won't allow it.
|
||||
12. 🔐 **Groundwork for YouTube Movies, Netflix, HBO Max, Disney+ and other protected videos:** the
|
||||
infrastructure to play them in VR, ready for the day they allow it
|
||||
([why they don't yet](#-what-about-netflix-max-and-the-others)).
|
||||
13. ✨ **Whatever else I can think of** (and whatever you ask for!).
|
||||
|
||||
### ⏳ The "as soon as it's possible" roadmap\*
|
||||
|
||||
@@ -433,6 +475,17 @@ You don't have to write code to make StreamXR better:
|
||||
|
||||
- 🐞 **Report bugs:** on GitHub, go to **Issues → New issue**, pick the form that fits, and paste
|
||||
the **Copy details** report from the VR button's popup.
|
||||
- 🧪 **Help test the live chat:** these parts are built from what YouTube is known to send, but I
|
||||
haven't caught them on a real stream yet. If you see one in VR, tell me whether it looked
|
||||
right (a **Bug report** if it didn't, or a comment on the **Live chat: help test** issue if it did):
|
||||
- 💰 **Super Chats** and **Super Stickers** (colored cards with the amount)
|
||||
- 🎉 **New members** and **gifted memberships** (green cards)
|
||||
- 🛡️ The **channel owner's** name on a yellow chip, and **moderators** in blue with a wrench
|
||||
- 🗑️ A **deleted message** disappearing, and a **pinned message** in the banner at the top
|
||||
- 😺 **Your channel's member emojis** inside a message you send
|
||||
- 🚫 A chat you **can't type in** (members only, slow mode...): YouTube's reason should show
|
||||
instead of the message field
|
||||
- 📺 An **ad** during a chat replay: the chat should carry on where it was
|
||||
- 🥽 **Try it on your headset:** if you have a headset other than the Steam Frame, tell me
|
||||
what works and what doesn't. The **Details** report (press every controller button first)
|
||||
is often all I need to support it ([how](#-adding-a-headset)).
|
||||
@@ -481,7 +534,7 @@ A reusable VR **shell** in two parts, the **core** (`src/core/`: the VR interfac
|
||||
and plugs in through its **provider** (a middle layer), its panels (built on five standard
|
||||
styles) and its page buttons. No build step and no ES modules: plain scripts loaded in
|
||||
order by `manifest.json` (one vendored library, Google's Material Color Utilities, in
|
||||
`third_party/`). There are no 3D formats: on desktop YouTube sends one eye only.
|
||||
`third_party/`). Most 3D videos reach desktop as one eye only, and StreamXR never bypasses the site's player to get more ([decision 0002](docs/decisions/0002-site-player-only.md)).
|
||||
|
||||
| Read | For |
|
||||
|---|---|
|
||||
@@ -500,8 +553,9 @@ order by `manifest.json` (one vendored library, Google's Material Color Utilitie
|
||||
|
||||
## 🥽 Adding a headset
|
||||
|
||||
The extension is built and tested on the Steam Frame only, because that's the only headset I
|
||||
have. Help for other headsets is very welcome; this section is the map. Open an
|
||||
The extension is built and tested on the Steam Frame, because that's the only headset I
|
||||
have; [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack) has reported the **Meta Quest 2** working through SteamVR too. Help for other
|
||||
headsets is very welcome; this section is the map. Open an
|
||||
issue before writing code (see [CONTRIBUTING.md](CONTRIBUTING.md)).
|
||||
|
||||
**What actually differs between headsets.** Almost nothing in the video side: WebXR hands
|
||||
|
||||
@@ -288,8 +288,10 @@ your eyes were at the last recenter, each piece turned to face you:
|
||||
- **Sizes:** every canvas at `layout.pxPerDegree` (26) px per degree, so text looks as big
|
||||
as before; side panels 900 × 1100 px (about 35° × 43°); the grids keep today's card size
|
||||
and fill the height (3 × 3).
|
||||
- Changing the Safe Zone (or recentering) lays everything out again, which also brings back
|
||||
a controls panel you moved.
|
||||
- Changing the Safe Zone (or recentering) lays everything out again. What you moved by a grab
|
||||
bar (the controls panel, a side) keeps its move: a recenter turns and shifts it with the
|
||||
layout, so it stays where it was relative to you (0.84.0; it used to come back). The global
|
||||
reset puts it back.
|
||||
- **0.65.0:** toolbars ordered top to bottom: Description, Comments, Channel (left);
|
||||
Search, Up Next, Browse (right); Browse's icon is Material Symbols' "browse". The
|
||||
toolbars are solid while their side has a panel open (glass when it has none, 0.65.2: the panel's edge
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# 0002: Play only what the site's own player plays
|
||||
|
||||
- **Status:** accepted (the maintainer, 2026-10-17)
|
||||
- **Prompted by:** a tester's report (u/TehSuperFruitSnack on Reddit) that the Meta Quest app
|
||||
shows VR180 videos in 3D and up to 8K, while StreamXR shows one eye and 4K at most.
|
||||
|
||||
## Context
|
||||
|
||||
StreamXR draws the `<video>` element that the site's own player is already playing; it never
|
||||
fetches a video stream itself. On desktop that has limits (checked in the browser, see
|
||||
[youtube.md](../youtube.md) "Formats and VR180"):
|
||||
|
||||
- **Most 3D VR180 videos reach desktop as one eye only.** YouTube removes the right eye
|
||||
before the stream reaches the browser (a "LEFT / RIGHT" test video shows LEFT in both
|
||||
eyes). Some videos reach desktop with both eyes side by side; those StreamXR can show in
|
||||
3D (a format to pick by hand).
|
||||
- **Some VR180 videos top out at 4K on desktop** (the player offers 2160s at most), while
|
||||
others reach 8K.
|
||||
|
||||
The Quest app most likely identifies itself to YouTube as a VR client and gets the full
|
||||
stereo, higher-resolution versions. Matching it would mean **bypassing YouTube's player**:
|
||||
asking YouTube for the streams as if StreamXR were that app, and playing them in a player of
|
||||
our own.
|
||||
|
||||
## Decision
|
||||
|
||||
**StreamXR doesn't bypass the site's player.** It shows what the site's own player plays.
|
||||
If a site starts sending stereo or higher resolutions to browsers, StreamXR shows them. This
|
||||
holds for every site, not just YouTube. Suggestions to bypass the player (from users,
|
||||
contributors or forks) get pointed here.
|
||||
|
||||
## Why: the risks of bypassing the player
|
||||
|
||||
1. **Rules and distribution.**
|
||||
- YouTube's Terms of Service don't allow accessing videos other than through YouTube's
|
||||
own player and interfaces, or getting around its ads.
|
||||
- The Chrome Web Store rejects extensions that break a site's terms or enable
|
||||
unauthorized downloading or streaming; that would block the planned store release.
|
||||
- It invites legal trouble: in 2020 the RIAA had youtube-dl taken down from GitHub,
|
||||
arguing that working around YouTube's scrambled stream addresses ("rolling cipher") was
|
||||
circumvention. GitHub reinstated it, but the claim was made.
|
||||
2. **It breaks constantly.**
|
||||
- Posing as another app means copying its client name, version and request format,
|
||||
which YouTube changes often and blocks when they're not genuine.
|
||||
- Stream addresses are scrambled by code that changes with player updates, and YouTube
|
||||
increasingly requires proof-of-origin tokens (BotGuard) that are hard to produce
|
||||
outside its own player.
|
||||
- Tools like yt-dlp need fixes every few weeks for this; StreamXR would join that race.
|
||||
3. **Rebuilding the site's player.**
|
||||
- StreamXR would need its own player feeding the video and audio streams to the browser
|
||||
(Media Source Extensions) and choosing qualities.
|
||||
- Captions, audio tracks, quality switching, live streams, chat replays tied to the video
|
||||
time, ads and Skip ad, and the resume position all come from YouTube's player today;
|
||||
each would need rebuilding or would break.
|
||||
4. **Ads and creators.** Playing the streams directly skips YouTube's ads: the thing YouTube
|
||||
fights hardest, and revenue the creators people are watching depend on.
|
||||
5. **Users' accounts and privacy.**
|
||||
- Requests sent with the user's signed-in session while posing as another app are the
|
||||
kind of traffic YouTube flags; at worst, an account gets warnings, extra verification
|
||||
or restrictions.
|
||||
- It needs a host permission for YouTube's video servers, against the project's
|
||||
"minimal permissions" goal, with a broader warning for users.
|
||||
|
||||
## Consequences
|
||||
|
||||
- 3D on one-eye VR180 videos, and more than 4K on those YouTube caps on desktop, are
|
||||
documented as desktop YouTube limits (the README's "Known issues"), not open work.
|
||||
- What desktop YouTube does send is used fully: 8K where offered, and (planned) a side-by-side 3D
|
||||
format for videos that arrive with both eyes.
|
||||
- A fork may decide otherwise; it then takes on the risks above itself.
|
||||
+72
-2
@@ -57,6 +57,50 @@ even where they look like something to "fix".
|
||||
covers the text.
|
||||
- The Previous / Next hover card sits 32 dp (about 4.7 cm) in front of and 2 cm above its
|
||||
button, in the controls panel's tilt.
|
||||
- **Live Chat (planned; the maintainer, 2026-10-16):** a **right-side panel**, where YouTube
|
||||
and Twitch put chat, opened from a Chat button on the right toolbar that shows **only on
|
||||
live streams** (and chat replays). Like the other side panels it shares the right zone (one
|
||||
open at a time), but it **doesn't fade**: its toolbar still fades, the chat stays up while
|
||||
you watch. Built in phases: **reading** first (messages, badges, Super Chats / Stickers,
|
||||
memberships, the pinned message, member emojis), then **reactions** (a row of reaction
|
||||
buttons on the panel), then **typing** with the existing VR keyboard (no second keyboard),
|
||||
which gains an **emoji layer** (standard emojis and the channel's member emojis).
|
||||
Paying (Super Chat, memberships) stays out of VR: a **"Support on desktop"** button opens
|
||||
YouTube's own screen in the browser window. YouTube's AI **"Chat summary"** banner is left
|
||||
out; pinned messages and the welcome notice are shown (the maintainer, 2026-10-16).
|
||||
**Reading built in 0.79.0** (working since 0.79.1): the chat **opens by itself** when a live
|
||||
stream starts, as on YouTube, the panel it replaced coming back when the stream ends
|
||||
(confirmed by the maintainer, 2026-10-16); tabs for YouTube's Top chat / Live chat views (the
|
||||
choice remembered). **Typing built in 0.81.0**: a message field and Send above the reactions;
|
||||
the keyboard's 😀 key opens its emoji pages (the channel's own emojis first). **Support on
|
||||
desktop** is a dialog (the maintainer, 2026-10-16): YouTube's "$" screen can't be opened
|
||||
from StreamXR (no button to click in its chat frame), so the button says what the stream
|
||||
offers and to use the $ button under the chat in YouTube's own window. **Top fans (0.82.0,
|
||||
as YouTube's chat does it, to check)**: your XP in a 👑 pill at the right end of the tab
|
||||
row; it opens the ranked list inside the chat panel (‹ back to the chat; your row pinned at
|
||||
the bottom); the top three's rank pills ("#1", in YouTube's colours) before their names.
|
||||
**While only the chat stays up** (everything else faded), a laser shows while it points at
|
||||
the chat and hides again when it leaves it, so you can tell exactly when you're on the chat
|
||||
(the maintainer, 2026-10-17: keep it so).
|
||||
- **Every side panel can be moved (the maintainer, 2026-10-16; built in 0.83.0):** the left and
|
||||
right panels all can, not only the chat. Moving a panel moves its **whole side**: the toolbar
|
||||
and every panel on that side follow (they open where you put them). Moving only; no
|
||||
resizing (the Panel size setting scales everything). **The handle is a grab bar beside the
|
||||
side's toolbar, on its video side** (the maintainer, 2026-10-16; 0.85.0): an upright pill
|
||||
with a drag handle, the same as the keyboard's and the controls panel's bars, so everything
|
||||
movable is grabbed the same way (a button there, tried in 0.84.1, invited a trigger click
|
||||
though it only works with the grip; a bar under the panel, 0.83.0, was hard to find). Its
|
||||
laser zone is wider than the pill. It works with the grip (whose icon turns into a hand
|
||||
there): the point you hold follows your hand and the side turns to face you; the toolbar
|
||||
stands a little further out to make room for it.
|
||||
- **Recenter keeps what you moved (the maintainer, 2026-10-16; 0.84.0; replaces "Recenter or a
|
||||
Safe Zone change puts it back"):** recentering turns the layout to where you're looking and
|
||||
brings your moved panels along, each where you put it relative to you (the controls panel,
|
||||
with the keyboard and Settings; each moved side). A Safe Zone or panel size change keeps them
|
||||
too. Only the global reset (the right stick held for 3 s) puts them back in their places.
|
||||
**A setting for the other way (the maintainer, 2026-10-16; 0.86.0):** Settings → Safe zone →
|
||||
"Recenter resets panels" (off by default, remembered; the global reset turns it off):
|
||||
while on, recentering also puts the moved panels back.
|
||||
|
||||
## Look
|
||||
|
||||
@@ -159,8 +203,10 @@ even where they look like something to "fix".
|
||||
morphing shape on a dark disc, Material 3 Expressive; 0.56.0) shows in the same spot.
|
||||
- **Controller icons (0.69.0):** Material 3 Expressive round tonal icon buttons (the colour
|
||||
theme's button colour: white in YouTube, pink in Material 3) with Material Symbols:
|
||||
play_pause (A), keyboard (B; keyboard_hide while the keyboard is up), ads_click (trigger), call_to_action (left D-pad: the
|
||||
panels), recenter (grip), replay_10 + forward_10 (stick; swap_vert while it scrolls,
|
||||
play_pause (A), keyboard (B; keyboard_hide while the keyboard is up), ads_click (trigger),
|
||||
bottom_panel_close / bottom_panel_open (left D-pad: hide the panels while they're up, show them
|
||||
while they're hidden; call_to_action until 0.86.0), recenter (grip; back_hand while it would
|
||||
grab a move bar or handle, 0.84.2), replay_10 + forward_10 (stick; swap_vert while it scrolls,
|
||||
menu_book while it turns pages). A held button's countdown: an M3E circular progress
|
||||
ring in the theme's red round the same button, the seconds left, then a tick.
|
||||
- **The trigger wakes everything (0.69.0):** with the panels hidden (auto-hide or the left
|
||||
@@ -177,6 +223,13 @@ even where they look like something to "fix".
|
||||
panels since 0.78.4), the search
|
||||
bar (3 s), the controllers and the lasers (3.5 s each, when held still). Each from 1 s to
|
||||
30 s or Never, with a Default button; remembered; the global reset puts them back.
|
||||
- **A trigger pull on empty space hides everything (0.88.0, the maintainer, 2026-10-17, from a
|
||||
user's request):** the controls panel, the side toolbars, the search bar and the lasers go at
|
||||
once, paused or not (a press that lets go of a menu, a dialog or typing does only that).
|
||||
Panels that stay up (the live chat) stay. A pull or pointing at a panel brings them back.
|
||||
- **"Auto-hide while paused" is a switch (0.88.0, the maintainer, 2026-10-17):** Settings → Auto-hide,
|
||||
off by default (paused, everything stays up, as before); on, the auto-hide waits run while
|
||||
paused too, to take in a scene. The default waits stay as they are.
|
||||
- Live streams count as "live" until you're 12 s behind, so a short pause stays red.
|
||||
- Unsubscribing asks first, in a dialog (0.67.0): "Unsubscribe from <channel>?" with Cancel
|
||||
and Unsubscribe (dark red), on the panels' glass (0.67.1); it floats in front of everything (56 dp) with the rest dimmed
|
||||
@@ -223,3 +276,20 @@ even where they look like something to "fix".
|
||||
- The lasers are smoothed against hand tremor (pointing, clicks and grabbing alike), with
|
||||
fast moves passing through at once. The user sets how much in Settings → Laser
|
||||
(Steadiness, Off to Steadiest; the middle is the default, which felt right in testing).
|
||||
- **Subscriptions (the maintainer, 2026-10-17):** your subscribed channels are a **Channels** tab
|
||||
in the You panel, right after Subscriptions (round channel pictures, a click opens the
|
||||
channel in the Channel panel). A channel's **notification bell** (All / Personalized / None)
|
||||
is a bell button beside Subscribe on the Channel panel, opening a small floating menu like
|
||||
the controls panel's menus. The Subscriptions feed was to show **community posts** (desktop's
|
||||
feed has none: 0.90.2), but not its
|
||||
shelves (the Shorts row, "Most relevant"), as on Home.
|
||||
- **Premium (the maintainer, 2026-10-17):** from the audit in `features/video-page.md`: 1080p
|
||||
Premium in the Quality menu and Jump ahead, with the Diamond; queueing isn't Premium on
|
||||
desktop and is its own item.
|
||||
- **Jump ahead sits at the seek bar's end** (the maintainer, 2026-10-17, 0.92.1), where Skip ad
|
||||
shows during ads, not in the button row: the Diamond, "Jump ahead", fast-forward.
|
||||
- **3D side by side on the cinema screen** (the maintainer, 2026-10-17, 0.93.0): its own
|
||||
format with Material Symbols' `3d_2` icon (0.93.3); 180° 3D side by side keeps `3d_rotation`.
|
||||
- **The Format button says "Format"** (the maintainer, 2026-10-17, 0.93.2), whatever's chosen;
|
||||
its icon shows the format, and the Format menu shows every format's icon and name. The
|
||||
cinema screen's icon is `2d_2`, 3D Cinema's `3d_2` (0.93.3).
|
||||
@@ -40,15 +40,19 @@ the same shape.
|
||||
- VR idea: a Subscribe button on the Channel panel (`channel-page.md`) and on
|
||||
Up Next cards. Also lets the controls panel's Subscribe button unsubscribe, which it
|
||||
can't do today (YouTube's confirm menu blocks it).
|
||||
2. [ ] **Shelves and posts in the feed** (small to medium)
|
||||
2. [ ] **Posts in the feed**: deferred (0.90.2, see "Deferred"). The shelves (a Shorts row,
|
||||
"Most relevant") are deferred too, as on Home.
|
||||
- Code: `p.shelves` (Shorts row, "Most relevant"), `p.posts`
|
||||
- VR idea: as on Home. Posts need the post cards from the channel Posts tab.
|
||||
3. [ ] **Manage list: your subscribed channels** (medium)
|
||||
3. [x] ~~**Manage list: your subscribed channels**~~ DONE (v0.90.0, works in the headset; sort pills 0.92.2: You →
|
||||
Channels, round pictures, a click opens the Channel panel)
|
||||
- Code: `channels()` -> [{ id, title, handle, subscribers, thumb, subscribed,
|
||||
notifications }]
|
||||
- VR idea: a list of channel rows (avatar, name, counts); clicking one opens its Channel
|
||||
panel. Needs a scrolling list rather than the card grid.
|
||||
4. [ ] **Notification bell per channel** (medium)
|
||||
4. [x] ~~**Notification bell per channel**~~ DONE (v0.90.0, works in the headset: the bell button
|
||||
beside Subscribed on the Channel panel, a floating menu of YouTube's choices; the Channels
|
||||
list shows each setting)
|
||||
- Code: `setNotifications(option)` with one of a channel's `notifications.options`
|
||||
(All / Personalized / None); `[]` means YouTube offers no bell for that channel.
|
||||
- VR idea: a bell button on each Manage row and on the Channel panel, opening a small
|
||||
@@ -78,3 +82,12 @@ needed for now (Subscribe from the controls panel and the Channel panel works in
|
||||
3. **Watch later / Liked videos / your playlists**: their browse ids are in
|
||||
`youtube-home.js` (`feed('watchLater')`, `feed('liked')`) but untested signed in;
|
||||
saving a video to a playlist needs another request.
|
||||
|
||||
## Deferred (the maintainer, 2026-10-17)
|
||||
|
||||
- **Shelves in the Subscriptions feed** (a Shorts row, YouTube's "Most relevant"): for the same
|
||||
reason as Home's shelves (`home-page.md`, "Deferred"): Shorts fit VR poorly. `p.shelves` is
|
||||
still read.
|
||||
- **Posts in the Subscriptions feed** (0.90.2): desktop's feed doesn't mix community posts in
|
||||
(a probe signed in found no post data in `FEsubscriptions`), so the Posts pill built in
|
||||
0.90.0 was always empty and was taken out. Posts stay on each channel's Posts tab.
|
||||
+56
-14
@@ -51,10 +51,25 @@ published; what matters from them is in this file and docs/youtube.md).
|
||||
`provider/cards.js`); check Browse and Search results show movies the same way.
|
||||
- [ ] **Premium features** (the maintainer, after 0.78.4): YouTube Premium's extras in VR, marked with
|
||||
Material Symbols' **Diamond** icon: **purple** when you don't have Premium (an upsell, like
|
||||
YouTube's own), **YouTube Red** when you do. To include: the Premium quality levels
|
||||
("1080p Premium" in the Quality menu: check they show and work), **Add to queue**, and
|
||||
Premium's other features (to list from the watch page: e.g. Jump ahead, downloads where
|
||||
they make sense in VR). Find out first how the page says whether you have Premium.
|
||||
YouTube's own), **YouTube Red** when you do. Find out first how the page says whether you
|
||||
have Premium (a probe signed in with Premium).
|
||||
- **Audit** (2026-10-17, from YouTube's help article "YouTube Premium benefits", answer
|
||||
6308116, computer and Android versions):
|
||||
|
||||
| Benefit | On desktop web? | In StreamXR |
|
||||
|---|---|---|
|
||||
| **1080p Premium** (enhanced bitrate, 1080p uploads only) | yes, an extra Quality entry | **Done (0.91.0, works in the headset):** its own Quality row with the Diamond (red with Premium; purple and greyed out without) |
|
||||
| **Jump ahead** (to the point most viewers skip to) | yes (double-click, Ctrl + →) | **Done (0.92.3, works in the headset):** a button at the seek bar's end (Skip ad's spot) with the Diamond, shown while YouTube's own pill is up; clicking it clicks that pill |
|
||||
| Ad-free videos | yes | Already: no ads means nothing to skip |
|
||||
| Continue watching across devices | yes (account) | Already: YouTube's player resumes |
|
||||
| Queue | for everyone on desktop (Premium only on phones) | Not Premium here: its own item (Add to queue, below) |
|
||||
| Downloads (offline) | desktop has a Download button | Deferred: watching in VR needs the PC online anyway |
|
||||
| Background play, picture-in-picture, Smart downloads, On-the-go mode, Auto speed | phones only | Out |
|
||||
| Higher-quality audio (256 kbps music) | apps and TVs only | Out |
|
||||
| YouTube Music app, Premium badges, Co-Watch with Meet, Afterparties, smart devices | other apps | Out |
|
||||
| Early access to new features (experiments) | varies | Out: they come and go |
|
||||
- [ ] **Add to queue** (desktop: for everyone, not Premium): queue a video from a card and
|
||||
play the queue in order. Find out first how the page's queue can be driven.
|
||||
|
||||
## Medium: new panels with scrolling or long text
|
||||
|
||||
@@ -84,18 +99,34 @@ published; what matters from them is in this file and docs/youtube.md).
|
||||
- v0.30.1: the creator's ❤ shows; like / dislike buttons (confirmed signed in).
|
||||
- Not yet: replies to replies (YouTube's newer nested threads), writing comments
|
||||
(writing needs the VR keyboard).
|
||||
- [ ] **Live chat** (medium to large, read-only first)
|
||||
- Code: none yet. YouTube's chat polls `live_chat/get_live_chat` with a continuation token
|
||||
(from the watch page's live chat data) and says how long to wait before the next poll;
|
||||
a finished stream's chat replay uses `live_chat_replay/get_live_chat_replay`, each
|
||||
message tagged with its video time. Both need probing live before writing the reader.
|
||||
- [ ] **Live chat** (medium to large, read-only first): **phase 1 (reading) built in 0.79.0**,
|
||||
working in the headset since 0.79.1 (checked by the maintainer).
|
||||
- Code: `youtube-chat.js` (the views, the chat page, polling every 2 s; docs/youtube.md,
|
||||
"Live chat") and `provider/live-chat.js` (the panel; Info style's `chat` content). A
|
||||
finished stream's **chat replay** plays along with the video since 0.87.0 (to check):
|
||||
fetched ahead with `live_chat/get_live_chat_replay`, each message shown at its time in the
|
||||
video, following pauses and seeks.
|
||||
- Messages: text with emoji images, author with badges (moderator, member, verified),
|
||||
Super Chats / Stickers (coloured, with the amount), membership messages.
|
||||
- VR idea: a tall narrow panel standing beside the cinema screen (or to the side in
|
||||
180°/360°), not in the chevron stack, so you can watch and read at once. Newest at the
|
||||
bottom, auto-scrolling, with "jump to newest" after scrolling back. A switch to hide it;
|
||||
only shown on live streams and chat replays (replay follows the video time and seeks).
|
||||
- Sending messages needs the VR keyboard (see Search), so it comes later.
|
||||
- Decided (design-choices.md, 2026-10-16): a **right-side panel** from a Chat button on the
|
||||
right toolbar, shown only on live streams and chat replays; it **doesn't fade** with its
|
||||
toolbar (a generic registry option, no chat field in the shell). Newest at the bottom,
|
||||
auto-scrolling, with "jump to newest" after scrolling back; a replay follows the video
|
||||
time and seeks.
|
||||
- Phases: 1. **reading** (messages, badges, Super Chats / Stickers, memberships, the pinned
|
||||
message as a banner, member emojis inline: 0.79.0); 2. **reactions** (a row of reaction
|
||||
buttons along the panel's bottom, sent with `youtubeChat.react()`: 0.80.0, working in the
|
||||
headset; other viewers' floating reactions deferred, see "Deferred"); 3. **typing** with the existing VR keyboard, which
|
||||
gains an **emoji layer** (standard emojis plus the set the panel hands it: the channel's
|
||||
member emojis), and a **"Support on desktop"** button (paying stays out of VR): built in
|
||||
0.81.0, to check (a message field with Send; the keyboard's 😀 pages; Support opens a dialog
|
||||
pointing to YouTube's own window). Then **Top fans** (0.82.0, to check): your XP in a 👑
|
||||
pill at the right of the chat's tabs, opening the ranked list in the panel (your row at
|
||||
the bottom), and the top three's "#1" / "#2" / "#3" pills in the chat.
|
||||
- Then **every side panel movable** (left and right): a grab bar beside each side's toolbar,
|
||||
on the video side (0.85.0; a bar under the panel in 0.83.0, a toolbar button in 0.84.1). Moving one moves its whole side (toolbar and panels);
|
||||
No resizing. Built in 0.83.0 (works in the headset); since 0.84.0 a recenter keeps the moves
|
||||
(only the global reset puts them back).
|
||||
- [x] ~~**Search**~~ DONE (v0.42.0: a "Search" chevron between Channel and Browse, Catalog
|
||||
style with a search field; v0.43.0: the in-VR keyboard, right controller's A)
|
||||
- ~~Code: `youtubeContent.search(query)`~~ (ads and the Shorts shelf are skipped)
|
||||
@@ -106,3 +137,14 @@ published; what matters from them is in this file and docs/youtube.md).
|
||||
- **Technical info**: `youtube.statsForNerds()` (codecs, resolution, bandwidth, buffer),
|
||||
`youtube.videoData()`, `youtube.loadedFraction()`. These could go in the VR Details panel.
|
||||
- **YouTube's own 360° view angle**: `youtube.sphericalView()`, `youtube.setSphericalView()`
|
||||
|
||||
## Deferred
|
||||
|
||||
Left out for now by the maintainer; each can come back if there is demand for it.
|
||||
|
||||
- **Other viewers' floating reactions in the live chat** (medium; the maintainer, 2026-10-17)
|
||||
- What: the emojis that float up over YouTube's chat when viewers press the reaction
|
||||
buttons.
|
||||
- Why: they arrive as `emojiFountainDataEntity` entity updates (seen once, in probe 9)
|
||||
whose shape is unknown, and there's no stream known to send many of them to build
|
||||
and test against. Sending your own reactions works (0.80.0).
|
||||
@@ -42,6 +42,13 @@ Steam Frame with SteamVR, desktop Chrome). For supporting other hardware, see th
|
||||
Vibration switch (Settings → Appearance) is greyed out with a note until a controller offers one, so it
|
||||
works by itself if a later Chrome adds it.
|
||||
|
||||
## Other headsets (reported by users)
|
||||
|
||||
- **Meta Quest 2** (reported by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack) on Reddit, after 0.88.1): works through SteamVR in desktop Chrome,
|
||||
8K videos included. Not tried by the maintainer, and not reported yet: how its buttons map
|
||||
(Copy details' `controllers` part would show it) and how it connects to the PC (Link,
|
||||
Air Link or another streaming app). Its controllers are drawn as Steam Frame models.
|
||||
|
||||
## Controller models
|
||||
|
||||
- `src/core/page/controllers/models.js` is generated from Valve's Steam Frame controller CAD
|
||||
|
||||
+14
-1
@@ -8,6 +8,19 @@ permissions, Firefox) are in [ARCHITECTURE.md](../ARCHITECTURE.md#known-limits).
|
||||
|
||||
- **Signed-in Home feeds** other than the main one, History, Playlists, Watch later and
|
||||
Liked videos (those were verified in 0.35) (`youtube-home.js`).
|
||||
- **Live chat typing (0.81.0)**: sending a message was checked from the console (text only);
|
||||
in the headset, check typing, Send / Enter, a channel emoji in a message (sent by its id, as
|
||||
YouTube's client does), and a chat you can't type in (YouTube's reason should show instead
|
||||
of the field: that shape wasn't seen yet).
|
||||
- **Live chat (0.79.0)**: written from probes of real chats (text messages, the welcome notice,
|
||||
emojis, banners) and, for what didn't show up during the probes, from the shapes YouTube
|
||||
is known to send: **Super Chats, Super Stickers, memberships and gifted memberships, the
|
||||
owner / moderator / member badges, deletions, pinned messages** (`youtube-chat.js`). Check
|
||||
them on a busy stream. (The README's "How you can help" asks users to report them.)
|
||||
- **Chat replays (0.87.0)**: work in the headset (plays along, pauses, seeks). Untried: an ad
|
||||
during a replay (its time could count as a seek and reload the chat). With YouTube's chat
|
||||
hidden the replay is still found (probe 11); if its chat frame were ever missing, there's no
|
||||
pop-out replay page to fall back on (HTTP 400), so it would end in "Couldn't load the chat".
|
||||
|
||||
## Known quirks
|
||||
|
||||
@@ -33,7 +46,7 @@ permissions, Firefox) are in [ARCHITECTURE.md](../ARCHITECTURE.md#known-limits).
|
||||
- The Channel header's subscribed state can be wrong for a channel whose Home tab features
|
||||
other channels; it's only used when the watch page doesn't say.
|
||||
- Text matching assumes **English** YouTube: some readers recognise YouTube's own words
|
||||
(the "All" chip, "Joined", an ad's "1 of 2"). This is about what YouTube writes;
|
||||
(the "All" chip, "Joined", an ad's "1 of 2", the Jump ahead pill). This is about what YouTube writes;
|
||||
StreamXR's own words follow the interface language (Settings → Language).
|
||||
- Replies to replies (YouTube's newer nested comment threads) aren't shown; one level is.
|
||||
|
||||
|
||||
+68
-15
@@ -2,7 +2,7 @@
|
||||
|
||||
One list of what's still open, gathered from the feature backlogs (`docs/features/`) and
|
||||
`docs/known-issues.md`, which hold the details. Tick or remove items as they're done.
|
||||
Last updated for 0.78.9.
|
||||
Last updated for 0.93.3.
|
||||
|
||||
## Top priority: before the first release
|
||||
|
||||
@@ -49,16 +49,34 @@ until another headset or two has been tried and the unverified items are cleared
|
||||
## The public roadmap (README, after release, in this order)
|
||||
|
||||
1. Immediate bug fixes (what the first users run into).
|
||||
2. Chrome Web Store release (one-click install, automatic updates, still free; needs the
|
||||
store's privacy questions answered: the README's "Your privacy" section covers them).
|
||||
3. YouTube Premium features (below: the Diamond icon, Premium qualities, Add to queue ...).
|
||||
4. Explore page tabs (the "Coming Soon" ones).
|
||||
2. Add to queue (free on desktop, not Premium; how the page's queue can be driven is to probe
|
||||
first: `features/video-page.md`).
|
||||
3. Explore page tabs (the "Coming Soon" ones).
|
||||
4. Done: Live chat (0.79.0 to 0.88.1), YouTube Premium (1080p Premium 0.91.0, Jump ahead 0.92.3).
|
||||
5. Commenting in-headset (writing comments and replies with the VR keyboard).
|
||||
6. Twitch.tv support (the first site after YouTube: a new provider, `src/sites/twitch/`).
|
||||
7. Groundwork for DRM-protected videos (YouTube Movies, HBO Max and Netflix first, then Disney+, Prime Video): providers and
|
||||
player ready for when browsers and the services allow protected video in WebXR (e.g. media
|
||||
layers); the README asks users to tell the services they want desktop XR support.
|
||||
8. Whatever else comes up.
|
||||
6. Chrome Web Store release as **1.0** (the maintainer, 2026-10-17: once the YouTube features are
|
||||
all in; one-click install, automatic updates, still free; needs the store's privacy
|
||||
questions answered: the README's "Your privacy" section covers them).
|
||||
7. Twitch.tv support (the first site after YouTube: a new provider, `src/sites/twitch/`; reuses
|
||||
the live chat panel).
|
||||
8. Jellyfin and Plex support (new providers, `src/sites/jellyfin/`, `src/sites/plex/`: no DRM on
|
||||
your own media). Self-hosted servers live at any address, so each needs host access to the
|
||||
user's server: an optional permission asked when it's turned on (the maintainer agrees on
|
||||
any permission first: AGENTS.md).
|
||||
9. Music streaming, Spotify first (its audio is DRM-protected, but sound plays as on the page;
|
||||
VR shows the player's controls, art and queue, no video to draw). Needs open.spotify.com's
|
||||
host permission (to agree on first).
|
||||
10. Barebones mode, **experimental** (the maintainer, 2026-10-17: kept out of 1.0 and after the
|
||||
full providers, since how well it works depends on each site and first impressions
|
||||
matter): any `<video>` on any site on the cinema screen with basic controls (play, seek,
|
||||
volume, format), a generic provider for sites without one. Likely shape: `activeTab` +
|
||||
`scripting` (it runs only on a page where you click StreamXR's button; nothing added to
|
||||
the install warning), to agree on first. Unprotected video only (DRM: see 11), and videos
|
||||
served cross-origin without CORS can't be drawn in WebGL, so many sites will refuse.
|
||||
11. Groundwork for DRM-protected videos (YouTube Movies, HBO Max and Netflix first, then Disney+, Prime Video): providers and
|
||||
player ready for when browsers and the services allow protected video in WebXR (e.g. media
|
||||
layers); the README asks users to tell the services they want desktop XR support.
|
||||
12. Whatever else comes up.
|
||||
|
||||
**As soon as it's possible** (README's second roadmap, ahead of everything above once the support exists):
|
||||
YouTube Movies (copy-protected: once YouTube allows protected video in desktop XR; showing
|
||||
@@ -68,19 +86,54 @@ controllers instead of "Oculus Touch"), native Steam Frame support (StreamXR in
|
||||
Chromium browser, no PC, once that browser supports WebXR), streaming services (HBO Max and
|
||||
Netflix first) once they allow protected video in desktop XR.
|
||||
|
||||
## Next: from u/TehSuperFruitSnack's Reddit feedback (after 0.88.1)
|
||||
|
||||
1. [ ] **Cubemap seams** (small): thin lines along the cube's edges on 360° cubemap ("MESH")
|
||||
videos; regular (equirectangular) 360° videos don't have them. Likely cause: the glass look
|
||||
(on by default) builds mipmaps of the video (`gl-utils.js` `videoMipmaps`), and the
|
||||
sphere's cubemap lookup (`video-sphere.js` `eacUv`) jumps across the texture where two faces
|
||||
meet, so the GPU picks the smallest mipmap along those lines. Check first: the seams should
|
||||
go away with the glass off (Settings → Appearance). Fix: in cubemap mode, sample the video
|
||||
at full resolution (an explicit level of detail, or gradients from the smooth view direction).
|
||||
- Test: [SheiKra 8K 360° roller coaster](https://www.youtube.com/watch?v=tR8ZtyhSDYw)
|
||||
(cubemap, 8K); for comparison, with no seams:
|
||||
[a regular 360° video](https://www.youtube.com/watch?v=Q7UwGaom0XA).
|
||||
2. [x] **3D side-by-side videos** (0.89.0, works in the headset; the glass per eye fixed in 0.90.1): some VR180 videos reach desktop with both eyes in
|
||||
one frame (LEFT | RIGHT), and StreamXR stretches the whole frame over the half sphere. Add a
|
||||
side-by-side format to the Format menu that gives each eye its half (`video-sphere.js`
|
||||
`uUvXform`, the per-eye hook); picked by hand, since YouTube's data doesn't mark these
|
||||
videos (auto-detecting by comparing the frame's halves could come later).
|
||||
- Test: [VR180 Left/Right](https://www.youtube.com/watch?v=ccU0imlNp5Q) (side by side:
|
||||
LEFT in the left eye, RIGHT in the right). For comparison, one eye only, which stays flat:
|
||||
[Left Right Test in VR 180](https://youtu.be/kle719aQvsc?t=8).
|
||||
- Not planned: 3D for videos that reach desktop as one eye, and more than 4K where desktop
|
||||
YouTube caps it (e.g. [this VR180 tour](https://www.youtube.com/watch?v=KJ-_LUrGhYE)):
|
||||
desktop YouTube limits ([decisions/0002](decisions/0002-site-player-only.md)).
|
||||
3. [x] **3D side by side on the cinema screen** (0.93.0, works in the headset): the same for
|
||||
regular (not VR180) 3D videos, each eye its half on the cinema screen.
|
||||
- Test: [Demo 3D[SBS] (HD : 1080P)](https://www.youtube.com/watch?v=u0hmMt5znX0) (half side
|
||||
by side: a 16:9 frame, each eye squeezed into half).
|
||||
|
||||
## Features to build (easiest first)
|
||||
|
||||
- [ ] **Movies in Up Next**: the price / rating in place of views and date, a yellow Movie tag
|
||||
(`features/video-page.md`).
|
||||
- [ ] **Premium features**: the Premium quality levels, Add to queue and the rest, marked with the
|
||||
Diamond icon (purple without Premium, YouTube Red with it) (`features/video-page.md`).
|
||||
- [x] **Premium features** (audited 2026-10-17, `features/video-page.md`): **1080p Premium** in the
|
||||
Quality menu and **Jump ahead**, marked with the Diamond (purple without Premium, YouTube Red
|
||||
with it). 1080p Premium (0.91.0) and Jump ahead (0.92.3) work in the headset. **Add to queue** is its own item
|
||||
(not Premium on desktop).
|
||||
- [ ] **Explore's "Coming Soon" tabs**: Live, Gaming, Music, Hype, Courses, Podcasts,
|
||||
Memberships (readers exist); Shorts, Movies & TV, Learning need more first
|
||||
(`features/home-page.md`).
|
||||
- [ ] **News**: its own tabs (Top stories, Sports ...) and the Sports feed.
|
||||
- [ ] **Subscriptions**: shelves and posts in the feed, the list of your channels, the bell per
|
||||
channel (`features/subscriptions-page.md`).
|
||||
- [ ] **End screen**, **Info cards**, **Live chat** (read-only first) (`features/video-page.md`).
|
||||
- [x] **Subscriptions** (0.90.0 to 0.92.2, work in the headset): the Channels tab with its sort pills, the bell on the Channel
|
||||
panel, posts in the feed; the feed's shelves deferred (`features/subscriptions-page.md`).
|
||||
- [ ] **Live chat**: reading (0.79.1) and reactions (0.80.0) work in the headset; typing, the
|
||||
keyboard's emoji pages and Support on desktop built in 0.81.0, Top fans (XP, the list, rank
|
||||
pills) in 0.82.0 and chat replays in 0.87.0 (all work in the headset). Other viewers' floating
|
||||
reactions are deferred (`features/video-page.md`, "Deferred"). Left: the unverified items in `known-issues.md`, which the
|
||||
README's "How you can help" asks users to check (`features/video-page.md`).
|
||||
- [ ] **End screen**, **Info cards** (`features/video-page.md`).
|
||||
- [ ] **Back button**: set aside (0.73.0); comes back on demand.
|
||||
|
||||
## Known quirks (`known-issues.md`)
|
||||
|
||||
+156
-4
@@ -19,6 +19,9 @@ backlogs in [features/](features/)):
|
||||
- `youtube-player.js` (`ns.youtube`): everything on the `#movie_player` API.
|
||||
- `youtube-page.js` (`ns.youtubePage`): Up Next (merged data + DOM scraping, load more,
|
||||
playlists), the controls panel's details, Subscribe / Like / Dislike, in-page navigation.
|
||||
In-page navigation is a `yt-navigate` event with a watch endpoint, sent from
|
||||
`ytd-page-manager`: sent from `ytd-app` itself, YouTube ignored it on a channel page (it
|
||||
worked on the watch page and Home), checked 2026-10-17.
|
||||
- `youtube-content.js` (`ns.youtubeContent`): metadata, description, owner, chapters,
|
||||
heatmap, end screen, cards, playlist, comments / replies, transcript, search.
|
||||
- `youtube-channel.js` (`ns.youtubeChannel`): any channel by id (`currentChannelId()` for the
|
||||
@@ -36,7 +39,18 @@ backlogs in [features/](features/)):
|
||||
Manage list `subscribed` is always true; one channel had only a stub `subscriptionButton`
|
||||
and no bell), subscribe / unsubscribe / `setNotifications` (they report ok only when
|
||||
YouTube's answer shows the new state). Filter chips: none on desktop for the tested
|
||||
account (the documented ones seem app-only).
|
||||
account (the documented ones seem app-only). **No community posts** in the desktop feed
|
||||
(signed in, 2026-10-17: no post data in `FEsubscriptions` at all). The Manage page
|
||||
(`FEchannels`, seen in the signed-in page's `ytInitialData`, 2026-10-17): the list is split
|
||||
into **titled shelves** (`shelfRenderer` inside the item section: "Purchased" for channels
|
||||
you're a member of, then "Subscribed"), and its **sort dropdown** (Most relevant, New
|
||||
activity, A-Z) is a set of `listItemViewModel`s with `entityKey`
|
||||
`channel_list_page_sort_chip_entity:<name>`, each tap updating a `chipEntity` (the
|
||||
dropdown's label) and sending a browse `continuationCommand` that reloads the list in that
|
||||
order (`sortsOf`, `channelsOn`).
|
||||
- `youtube-chat.js` (`ns.youtubeChat`): a live stream's chat ("Live chat" below): `info()`
|
||||
(its views), `open(token)` (a view's chat page), `poll(next)`, and the chat items as plain
|
||||
messages and changes (add, remove, banner).
|
||||
|
||||
The provider (`provider/`) is the only code that calls these modules on the shell's behalf.
|
||||
|
||||
@@ -93,10 +107,35 @@ The formats are in `src/streamxr/shared/formats.js`; YouTube's detection is the
|
||||
from `getAvailableQualityData()` (`qualityLabel`, e.g. "1080p60", or "2160s60" for a 360° /
|
||||
VR180 video: "s" for spherical) where the player has it
|
||||
(`youtube-player.js` `quality().labels`); with Premium it also lists "1080p Premium",
|
||||
skipped in favour of the plain entry. Unverified in the headset (see known-issues.md).
|
||||
kept apart as its own level (0.91.0). Seen signed in with Premium (2026-10-17): the entry
|
||||
has `formatId` "721", `quality` "hd1080", `paygatedQualityDetails.paygatedIndicatorText`
|
||||
"Enhanced bitrate", `isPlayable` true, beside the plain 1080p (`formatId` "399"); it's
|
||||
chosen with `setPlaybackQualityRange(q, q, formatId)` and `getPlaybackQualityLabel()` names
|
||||
the one playing. **Whether you have Premium:** the top bar's logo,
|
||||
`ytInitialData.topbar.desktopTopbarRenderer.logo.topbarLogoRenderer.iconImage.iconType`
|
||||
is `YOUTUBE_PREMIUM_LOGO` (`youtube-player.js` `hasPremium`). **Jump ahead:** the page has
|
||||
no data for it, and the player's `prefetchJumpAhead()` returns undefined and moves nothing.
|
||||
YouTube shows a **Jump ahead pill** in the player's "timely actions" overlay at the moments
|
||||
it applies (`.ytp-timely-actions-overlay ytw-timely-action-view-model button`, its text
|
||||
"Jump ahead"), and a script's `click()` on it jumps (17 s -> 124 s, 2026-10-17), so
|
||||
`youtube-player.js` `jumpAhead()` clicks it and `jumpAheadOffered()` tells whether it's up.
|
||||
A script-sent Ctrl + → keydown on `#movie_player` jumped from the console but did nothing
|
||||
from VR.
|
||||
|
||||
- Desktop YouTube sends **one eye only**, even for 3D videos, so there are no 3D formats.
|
||||
- Formats: `flat` (cinema screen), `360mono`, `360eac`, `180mono`.
|
||||
- Desktop YouTube sends **one eye only** for most 3D videos (checked 2026-10-17 with frames
|
||||
from the page's `<video>`): the "Left Right Test in VR 180" (`kle719aQvsc`, 1920×2160) and
|
||||
a 3D VR180 tour (`KJ-_LUrGhYE`, 3840×2160, 2160s at most on desktop) hold the left eye
|
||||
only. **Some arrive side by side** (both eyes in one frame): "VR180 Left/Right"
|
||||
(`ccU0imlNp5Q`, 3840×2160, LEFT | RIGHT). Nothing in the player response tells them apart
|
||||
(all `MESH`, `vrConfig.partialSpherical`, no stereo field). The Quest app gets both eyes
|
||||
and higher resolutions, most likely as a VR client; StreamXR doesn't bypass the page's
|
||||
player to get them ([decisions/0002](decisions/0002-site-player-only.md)).
|
||||
- 8K reaches desktop where YouTube offers it, e.g. an 8K 360° video (`tR8ZtyhSDYw`):
|
||||
`4320s`, 7680×3840, AV1 (itag 571).
|
||||
- Formats: `flat` (cinema screen), `flatsbs` (the cinema screen in 3D side by side: 0.93.0;
|
||||
e.g. "Demo 3D[SBS]", `u0hmMt5znX0`, a RECTANGULAR 16:9 frame, each eye squeezed into half),
|
||||
`360mono`, `360eac`, `180mono`, `180sbs` (3D side by side,
|
||||
picked by hand: 0.89.0).
|
||||
- "MESH" 360° videos use an **equi-angular cubemap**, 3×2: top row left | front | right,
|
||||
bottom row down | back | up; down and up rotated 270° clockwise, back 90° (the same as
|
||||
ffmpeg's `eac`). Verified against a real frame.
|
||||
@@ -153,6 +192,119 @@ Checked on a Lofi Girl stream:
|
||||
as live (the player says "not at head" 15 s behind right after joining).
|
||||
- No storyboard previews on live streams.
|
||||
|
||||
## Live chat
|
||||
|
||||
Probed signed in on a members' live stream and a busy public one (2026-10-16, console
|
||||
snippets); read by `youtube-chat.js` since 0.79.0:
|
||||
|
||||
- **The watch page** has a `liveChatRenderer` (in `conversationBar`) naming the views ("Top
|
||||
chat", "Live chat" in `sortFilterSubMenuRenderer.subMenuItems`), but **its tokens don't
|
||||
work**: `/live_chat?continuation=<one of them>` answers 200 with an empty page (~79 KB,
|
||||
`window["ytInitialData"] = <!-- …`, no chat), and `live_chat/get_live_chat` answers
|
||||
**HTTP 400** (probe 6, 2026-10-16). Use the **chat frame's** tokens (its own view selector:
|
||||
different tokens, each loading a full chat page, ~455 KB) or, with no frame on the page, the
|
||||
**pop-out page**'s (`/live_chat?is_popout=1&v=<video id>`: ~452 KB of data, but no
|
||||
`liveChatContinuation`; its view selector is read wherever it is: to confirm on a real page).
|
||||
- **The chat page / chat frame** (`#chatframe`, same origin): its `ytInitialData` is shaped like
|
||||
a chat update, `continuationContents.liveChatContinuation`, with `continuations[0]`
|
||||
(`invalidationContinuationData`: the token to poll with), `header` (the view selector
|
||||
again, with its own reload tokens), `ticker`, `actionPanel` (the message box, the emoji picker,
|
||||
the reactions, the send endpoint `sendLiveChatMessageEndpoint`), `emojis` (the channel's
|
||||
custom emojis), `participantsList`, `itemList`. Its `actions` are gone once the frame has
|
||||
shown them (`undefined` when read later). The chat page's HTML writes it as
|
||||
`window["ytInitialData"] = {…};`: fetching `/live_chat?continuation=<a view's reload token>`
|
||||
and reading that (brace matching, then `JSON.parse`) works without any frame, and comes
|
||||
with the recent messages in `actions` (77 on a busy stream). That's how to open the
|
||||
**Live chat** view (every message) whatever view the page's frame shows (Top chat by default).
|
||||
- **Polling:** `live_chat/get_live_chat` `{ continuation }` with the invalidation token
|
||||
answers in ~100-200 ms with `actions` and the next `invalidationContinuationData`
|
||||
(`timeoutMs: 10000`). YouTube's own frame doesn't wait that long: it is told about new
|
||||
messages by a push service (`/punctual/v1/chooseServer`, `/punctual/multi-watch/channel`).
|
||||
**Polling every 2 s works** (20 polls in a row on a busy stream: no errors, new messages
|
||||
in each, no duplicates; each poll's token is the previous answer's).
|
||||
- **A message:** `addChatItemAction.item.liveChatTextMessageRenderer`: `message.runs`
|
||||
(text and emojis), `authorName.simpleText`, `authorPhoto.thumbnails` (32 and 64 px),
|
||||
`authorExternalChannelId`, `id`, `timestampUsec`, `contextMenuEndpoint` ("Chat actions").
|
||||
The action also has a `clientId`.
|
||||
- **Other items seen** (in `addChatItemAction.item`): `liveChatPlaceholderItemRenderer`
|
||||
(only `id` and `timestampUsec`: a message YouTube holds back; none was filled in within
|
||||
40 s, so skip them), `liveChatViewerEngagementMessageRenderer` (YouTube's "Welcome to live
|
||||
chat! Remember to guard your privacy…" notice: `icon`, `message.runs`, a "Learn more"
|
||||
`actionButton` to support.google.com).
|
||||
- **Banners:** `addBannerToLiveChatCommand.bannerRenderer.liveChatBannerRenderer` (`contents`,
|
||||
`actionId`, `bannerType`, `bannerProperties` with `isEphemeral` / `autoCollapseDelay`).
|
||||
Seen: `LIVE_CHAT_BANNER_TYPE_CHAT_SUMMARY`, an AI "Chat summary" ("Auto-generated
|
||||
experiment • Quality may vary") with thumbs up / down, gone after 12-30 s. Pinned messages
|
||||
use the same command (not seen yet).
|
||||
- Not seen yet: Super Chats / Stickers, memberships, ticker items, deletions, replacements.
|
||||
- **Custom emojis** (`liveChatContinuation.emojis`, 132 on that channel): `emojiId`
|
||||
(`<channel id>/<id>`), `shortcuts` (`:_C80:`, `:C80:`), `searchTerms`, `image.thumbnails`
|
||||
(24 and 48 px on yt3.ggpht.com), `isCustomEmoji`.
|
||||
- **Reactions** (`reactionControlPanelViewModel.expandedButtons`): ❤ 😄 🎉, each a
|
||||
`reactionControlPanelButtonViewModel` with `emojiId`, `buttonIcon` (Noto emoji PNG on
|
||||
fonts.gstatic.com), `a11yLabel` ("Send ❤") and `onTap.innertubeCommand`: a
|
||||
`feedbackEndpoint` (`feedbackToken`) to `/youtubei/v1/feedback`. They're in the chat page's
|
||||
`actionPanel` (absent when the stream has no reactions, or signed out). Sent by
|
||||
`youtubeChat.react()` as YouTube's web client sends a feedback token: `{ feedbackTokens:
|
||||
[token], isFeedbackTokenUnencrypted: false, shouldMerge: false }`, the answer's
|
||||
`feedbackResponses[].isProcessed` saying whether it was taken (0.80.0; checked from the
|
||||
console on a live stream, 2026-10-16: taken). The set varies by stream (that one offered five,
|
||||
not ❤ 😄 🎉). `emojiId` is written without the
|
||||
emoji selector (❤ = U+2764 alone), which fonts draw as a plain glyph: add U+FE0F.
|
||||
- **The message box** (probe 7, 2026-10-16; the chat page's `actionPanel`):
|
||||
`liveChatMessageInputRenderer` with `inputField.liveChatTextInputFieldRenderer`
|
||||
(`placeholder` "Chat...", `maxCharacterLimit`, `emojiCharacterCount`: 10, what an emoji
|
||||
counts for), `sendButton…sendLiveChatMessageEndpoint` (`params`, `clientIdPrefix`, apiUrl
|
||||
`/youtubei/v1/live_chat/send_message`), `pickers` (`emojiPickerRenderer`,
|
||||
`liveChatProductPickerPanelViewModel`), `pickerButtons` (an icon toggle, the reactions),
|
||||
`jewelsGiftsButton`. Where you can't chat, the action panel holds something else with a
|
||||
`message` (not seen yet).
|
||||
- **Sending**: `live_chat/send_message` `{ params, clientMessageId, richMessage: { textSegments:
|
||||
[{ text } | { emojiId }] } }` posted a message (checked from the console: the answer's
|
||||
`actions` echo it as an `addChatItemAction`, plus a `runAttestationCommand`). A channel
|
||||
emoji as `{ emojiId }` is how YouTube's own client sends one (not checked yet).
|
||||
- **Support** ("$", the product picker): its items are `liveChatProductPickerPanelItemViewModel`s
|
||||
("Gifts", "Super Chat", "Membership gifting", "Free Member Milestone Chat" on a members'
|
||||
stream). The chat frame's document had no button to click for it (only icon definitions), so
|
||||
StreamXR doesn't open it: its Support button says to use YouTube's own window.
|
||||
- **Chat replays** (a finished stream's chat; probe 10, 2026-10-16): the watch page's
|
||||
`liveChatRenderer.isReplay` is true and its views are "Top chat replay" / "Live chat replay".
|
||||
The chat frame is `/live_chat_replay`; its data (shown actions already gone, as for live chat)
|
||||
has `continuations` [`liveChatReplayContinuationData` (`continuation`,
|
||||
`timeUntilLastMessageMsec`), `playerSeekContinuationData` (`continuation`)] and the same view
|
||||
selector (reload tokens: `/live_chat_replay?continuation=<token>` reads, ~295 KB, with the
|
||||
chat). `live_chat/get_live_chat_replay` `{ continuation, currentPlayerState: { playerOffsetMs } }`
|
||||
is a **cursor**: each answer is the next chunk (~45 messages, minutes of chat) as
|
||||
`replayChatItemAction` (`videoOffsetTimeMsec`, the message's time in the video, and its
|
||||
`actions`), whatever position is sent; the **first** token with another position starts
|
||||
again from there (a seek: asked at 10:35, the chunk started at 10:36). So a reader fetches
|
||||
ahead and shows each message when the video reaches its time (`youtube-chat.js`
|
||||
`pollReplay`, `provider/live-chat.js`, 0.87.0). The frame itself made no chat requests while
|
||||
probed (`/youtubei/v1/att/get` only); `playerSeekContinuationData` isn't used yet.
|
||||
With YouTube's chat **hidden** on the page (probe 11), the watch page keeps the replay's
|
||||
`liveChatRenderer` (its `initialDisplayState` still says EXPANDED) and the chat frame element
|
||||
stays, so the chat is still found. There's **no pop-out replay page**:
|
||||
`/live_chat_replay?is_popout=1&v=<id>` answers HTTP 400 (the fallback only works for live chat).
|
||||
- **Top fans** (probes 8 and 9, 2026-10-16):
|
||||
- The chat header's `viewerLeaderboardEntryPoint.liveViewerLeaderboardChatEntryPointViewModel`:
|
||||
a `defaultButton` and a `pointsButton` (crown icons, "Top fans"), each tapping a
|
||||
`showEngagementPanelEndpoint` (tag `PAlive_viewer_leaderboard`, `globalConfiguration.params`),
|
||||
plus `pointsEntityKey` / `entryPointStateEntityKey`.
|
||||
- **Your XP** is an entity: `frameworkUpdates.entityBatchUpdate.mutations[]` with that
|
||||
`entityKey` and `payload.liveViewerLeaderboardPointsEntity` (`pointsCompactText` "0 XP",
|
||||
`pointsValue`); it comes in the chat page and, when it changes, in the polls' answers.
|
||||
- **The list**: `get_panel` `{ panelId: 'PAlive_viewer_leaderboard', params }` (with the params
|
||||
alone, `browse` or other guesses: HTTP 400) answers
|
||||
`content.engagementPanelSectionListRenderer` with `content.sectionListRenderer.contents[]`
|
||||
(50 on a busy stream) of `itemSectionRenderer` > `liveLeaderboardItemViewModel`: `rank.content`
|
||||
("1"), `points.content` ("30 XP"), `avatar.avatarViewModel.image.sources[0].url`,
|
||||
`content.liveLeaderboardItemChannelContentViewModel` (`displayName.content`, `badge`: the
|
||||
rank's button). Its `footer` is **your row** (`rank`, `points`, `avatar`, and
|
||||
`liveLeaderboardItemActionContentViewModel.actionDescription` with a gift button).
|
||||
- **Rank marks on messages**: `liveChatTextMessageRenderer.beforeContentButtons[]` >
|
||||
`buttonViewModel` (`iconName` CROWN_FILLED, `title` "#1", `customBackgroundColor` /
|
||||
`customFontColor`, ARGB; tapping opens the same panel): on the top three's messages.
|
||||
|
||||
## Playlists
|
||||
|
||||
- The player (getPlaylist / getPlaylistIndex) and the page's sidebar hold only a WINDOW of
|
||||
|
||||
+4
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "StreamXR",
|
||||
"version": "0.78.9",
|
||||
"version": "0.93.3",
|
||||
"description": "Watch streaming video in a SteamVR headset: 360° and VR180 videos around you, regular videos on a cinema screen. Supports YouTube.",
|
||||
"action": {
|
||||
"default_title": "StreamXR: open YouTube and click the VR button (the headset)",
|
||||
@@ -61,6 +61,7 @@
|
||||
"src/core/page/vr-panel/styles/kit.js",
|
||||
"src/core/page/vr-panel/styles/grid.js",
|
||||
"src/core/page/vr-panel/styles/thread.js",
|
||||
"src/core/page/vr-panel/styles/chat.js",
|
||||
"src/core/page/vr-panel/styles/queue.js",
|
||||
"src/core/page/vr-panel/styles/catalog.js",
|
||||
"src/core/page/vr-panel/styles/profile.js",
|
||||
@@ -94,6 +95,7 @@
|
||||
"src/sites/youtube/page/youtube-channel.js",
|
||||
"src/sites/youtube/page/youtube-home.js",
|
||||
"src/sites/youtube/page/youtube-subscriptions.js",
|
||||
"src/sites/youtube/page/youtube-chat.js",
|
||||
"src/sites/youtube/page/provider/strings-en.js",
|
||||
"src/sites/youtube/page/provider/strings-es.js",
|
||||
"src/sites/youtube/page/provider/badges.js",
|
||||
@@ -106,6 +108,7 @@
|
||||
"src/sites/youtube/page/provider/description.js",
|
||||
"src/sites/youtube/page/provider/comments-panel.js",
|
||||
"src/sites/youtube/page/provider/settings.js",
|
||||
"src/sites/youtube/page/provider/live-chat.js",
|
||||
"src/sites/youtube/page/provider/panels.js",
|
||||
"src/sites/youtube/page/provider/extras.js",
|
||||
"src/sites/youtube/page/provider/provider.js",
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
const ICONS = {
|
||||
playpause: ['play_pause'],
|
||||
recenter: ['recenter'],
|
||||
grab: ['back_hand'], // the grip while it grabs (a move bar or handle, a resize corner) instead of recentering
|
||||
pointer: ['ads_click'], // the trigger: point and click with the laser
|
||||
panel: ['call_to_action'], // show / hide the panels (a panel with its bottom bar)
|
||||
panel: ['bottom_panel_close'], // hide the panels (they're up)
|
||||
panelopen: ['bottom_panel_open'], // show the panels again (they're hidden)
|
||||
seek: ['replay_10', 'forward_10'], // the stick's left / right: back / forward 10 s
|
||||
scroll: ['swap_vert'], // the stick scrolls the text you point at
|
||||
keyboard: ['keyboard'],
|
||||
|
||||
+14
-1
@@ -45,6 +45,19 @@
|
||||
const aroundPivot = (pivot, rot) =>
|
||||
multiply(translation(pivot[0], pivot[1], pivot[2]), multiply(rot, translation(-pivot[0], -pivot[1], -pivot[2])));
|
||||
|
||||
// The inverse of a pose made only of a rotation and a translation (no scaling): the rotation
|
||||
// turned back (transposed), and the translation undone in that turned-back frame.
|
||||
function invertRigid(m) {
|
||||
const o = identity(), tx = m[12], ty = m[13], tz = m[14];
|
||||
o[0] = m[0]; o[1] = m[4]; o[2] = m[8];
|
||||
o[4] = m[1]; o[5] = m[5]; o[6] = m[9];
|
||||
o[8] = m[2]; o[9] = m[6]; o[10] = m[10];
|
||||
o[12] = -(m[0] * tx + m[1] * ty + m[2] * tz);
|
||||
o[13] = -(m[4] * tx + m[5] * ty + m[6] * tz);
|
||||
o[14] = -(m[8] * tx + m[9] * ty + m[10] * tz);
|
||||
return o;
|
||||
}
|
||||
|
||||
// Position p as seen after matrix m (rotation + translation).
|
||||
const transformPoint = (m, p) => [
|
||||
m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
|
||||
@@ -76,7 +89,7 @@
|
||||
}
|
||||
|
||||
ns.math3d = {
|
||||
DEG, identity, multiply, translation, axisAngle, rotationY, aroundPivot,
|
||||
DEG, identity, multiply, translation, axisAngle, rotationY, aroundPivot, invertRigid,
|
||||
transformPoint, transformDir, cross, dot, length, normalize, yawOf,
|
||||
};
|
||||
})(globalThis.__streamxr = globalThis.__streamxr || {});
|
||||
@@ -1,6 +1,7 @@
|
||||
// The "Auto-hide" settings tab, which belongs to the shell: how long each part of the
|
||||
// interface waits, unused, before it hides or fades. One slider each (from 1 s to 30 s, or
|
||||
// Never), with a Default button; remembered.
|
||||
// Never), with a Default button; and a switch, "Auto-hide while paused": whether those waits also
|
||||
// run while the video is paused (off by default: paused, everything stays up). Remembered.
|
||||
// panels: the controls panel and the open panels (and the lasers with them), while
|
||||
// the video plays (vr-panel.js)
|
||||
// bars: the side toolbars and their open panels, while the video plays (vr-panel.js)
|
||||
@@ -10,10 +11,11 @@
|
||||
// A site's Options panel includes it as one of its tabs.
|
||||
//
|
||||
// ns.vrFadeOptions.seconds(name) -> the wait in seconds (Infinity: never)
|
||||
// ns.vrFadeOptions.hideWhilePaused() -> bool (the switch)
|
||||
// ns.vrFadeOptions.content() -> the Options style's 'rows' content
|
||||
// ns.vrFadeOptions.event(ev) -> true if it was one of this tab's controls
|
||||
// ('slider:fade-<name>', 'default:fade-<name>')
|
||||
// ns.vrFadeOptions.reset() -> back to the defaults (the global reset)
|
||||
// ('slider:fade-<name>', 'default:fade-<name>', 'switch:fade-paused')
|
||||
// ns.vrFadeOptions.reset() -> back to the defaults, the switch off (the global reset)
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
@@ -30,6 +32,8 @@
|
||||
for (const n of NAMES) if (n in saved && (saved[n] === null || STOPS.includes(saved[n]))) times[n] = saved[n];
|
||||
}
|
||||
const remember = () => ns.storage.setJSON(KEY, times);
|
||||
const PAUSED_KEY = 'hideWhilePaused'; // storage.js name: 'on' while the switch is on
|
||||
const hideWhilePaused = () => ns.storage.get(PAUSED_KEY) === 'on';
|
||||
|
||||
const seconds = name => (times[name] === null ? Infinity : times[name] != null ? times[name] : DEFAULTS[name]);
|
||||
|
||||
@@ -42,11 +46,16 @@
|
||||
return {
|
||||
kind: 'rows', note: tr('fade.note'),
|
||||
rows: NAMES.map(n => ({ label: tr(`fade.${n}`), hint: tr(`fade.${n}Hint`),
|
||||
control: { type: 'slider', id: `fade-${n}`, fraction: fractionOf(times[n]), text: text(times[n]), enabled: true, defaultButton: true } })),
|
||||
control: { type: 'slider', id: `fade-${n}`, fraction: fractionOf(times[n]), text: text(times[n]), enabled: true, defaultButton: true } }))
|
||||
.concat({ label: tr('fade.paused'), hint: tr('fade.pausedHint'), control: { type: 'switch', id: 'fade-paused', on: hideWhilePaused() } }),
|
||||
};
|
||||
}
|
||||
|
||||
function event(ev) {
|
||||
if (ev.type === 'switch' && ev.value === 'fade-paused') {
|
||||
if (hideWhilePaused()) ns.storage.remove(PAUSED_KEY); else ns.storage.set(PAUSED_KEY, 'on');
|
||||
return true;
|
||||
}
|
||||
const n = typeof ev.value === 'string' && ev.value.startsWith('fade-') ? ev.value.slice(5) : null;
|
||||
if (!NAMES.includes(n)) return false;
|
||||
if (ev.type === 'slider') times[n] = stopAt(ev.fraction);
|
||||
@@ -57,7 +66,7 @@
|
||||
}
|
||||
|
||||
// Back to the defaults (the global reset); forget the choices.
|
||||
function reset() { Object.assign(times, DEFAULTS); ns.storage.remove(KEY); }
|
||||
function reset() { Object.assign(times, DEFAULTS); ns.storage.remove(KEY); ns.storage.remove(PAUSED_KEY); }
|
||||
|
||||
ns.vrFadeOptions = { seconds, content, event, reset, DEFAULTS };
|
||||
ns.vrFadeOptions = { seconds, hideWhilePaused, content, event, reset, DEFAULTS };
|
||||
})(globalThis.__streamxr = globalThis.__streamxr || {});
|
||||
@@ -6,10 +6,13 @@
|
||||
// The layout comes from keyboard-options.js (the Keyboard settings tab): a chosen style and
|
||||
// language, or by default the computer's keyboard layout and the browser's language. The
|
||||
// space bar names the language, and the symbols page has a row of its accented letters.
|
||||
// Shift (tap: the next letter; tap twice: caps lock), "?123" for a page of symbols, and a
|
||||
// key that hides the keyboard.
|
||||
// Shift (tap: the next letter; tap twice: caps lock), "?123" for a page of symbols, 😀 for
|
||||
// pages of emojis (the ones the panel being typed into hands over first, e.g. a channel's
|
||||
// own, drawn as pictures; then a standard set), and a key that hides the keyboard.
|
||||
//
|
||||
// const kb = ns.vrKeyboard.create();
|
||||
// kb.setEmojis([{ text, image, label }]) the panel's own emojis (text: what the key types,
|
||||
// e.g. ':_wave:'; image: its picture's URL); [] for none
|
||||
// kb.canvas, kb.aspect (height / width), kb.version() (changes when its look changes),
|
||||
// kb.animating() (a key's hover / press is still easing: redraw)
|
||||
// kb.draw({ hover, pressed, target }) hover / pressed: its own control ids; target: what
|
||||
@@ -34,14 +37,31 @@
|
||||
const BAR = { w: 240, h: 30, zone: 500 }, CORNER = { r: 34, hit: 52 }; // the grab bar's pill (zone: its clickable width)
|
||||
|
||||
// Physical keys by row (KeyboardEvent.code), with their widths in key units; null = a gap.
|
||||
// Specials: 'Backspace', 'Enter', 'Shift', 'Space', 'Symbols' (?123 / ABC), 'Hide'.
|
||||
// Specials: 'Backspace', 'Enter', 'Shift', 'Space', 'Symbols' (?123 / ABC), 'Emoji' (the
|
||||
// emoji pages), 'Hide'.
|
||||
const BOTTOM = [['Symbols', 2], ['Emoji', 1.5], ['Space', 9], ['Hide', 2.5]];
|
||||
const LETTERS = [
|
||||
[['Backquote', 1], ...'1234567890'.split('').map(d => [`Digit${d}`, 1]), ['Minus', 1], ['Equal', 1], ['Backspace', 2]],
|
||||
[[null, 0.75], ...'QWERTYUIOP'.split('').map(c => [`Key${c}`, 1]), ['BracketLeft', 1], ['BracketRight', 1], ['Backslash', 1], [null, 1.25]],
|
||||
[[null, 1], ...'ASDFGHJKL'.split('').map(c => [`Key${c}`, 1]), ['Semicolon', 1], ['Quote', 1], ['Enter', 3]],
|
||||
[['Shift', 2.25], ['IntlBackslash', 1], ...'ZXCVBNM'.split('').map(c => [`Key${c}`, 1]), ['Comma', 1], ['Period', 1], ['Slash', 1], ['Shift', 1.75]],
|
||||
BOTTOM,
|
||||
];
|
||||
// The emoji pages ('emo:<n>' keys, n counting from the page's first): 13 across and 4 down,
|
||||
// Backspace, ‹ (the page before), Enter and › (the page after) at the rows' ends.
|
||||
const EMOJI_COLS = 13, EMOJI_ROWS = 4, PER_EMOJI_PAGE = EMOJI_COLS * EMOJI_ROWS;
|
||||
const EMOJI_ENDS = ['Backspace', 'EmojiPrev', 'Enter', 'EmojiNext'];
|
||||
const emojiRows = count => [
|
||||
...EMOJI_ENDS.map((end, r) => [...Array.from({ length: EMOJI_COLS }, (_, c) => (r * EMOJI_COLS + c < count ? [`emo:${r * EMOJI_COLS + c}`, 1] : [null, 1])), [end, 2]]),
|
||||
[['Symbols', 2.5], ['Space', 10], ['Hide', 2.5]],
|
||||
];
|
||||
// The standard emojis offered after the panel's own (the faces, hands and symbols people use
|
||||
// most in chats).
|
||||
const STANDARD_EMOJIS = ('😀 😃 😄 😁 😆 😅 🤣 😂 🙂 😉 😊 😇 🥰 😍 🤩 😘 😋 😛 😜 🤪 😝 🤗 🤭 🤔 🤐 😐 😑 😶 😏 😒 '
|
||||
+ '🙄 😬 😌 😔 😪 😴 😷 🤒 🥵 🥶 😵 🤯 🤠 🥳 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 '
|
||||
+ '😓 😩 😫 🥱 😤 😡 😠 🤬 😈 💀 💩 🤡 👻 👽 🤖 😺 😹 😻 🙈 🙉 🙊 💯 💢 💥 💫 💦 💨 💬 💤 👋 🤚 ✋ 👌 🤌 ✌️ 🤞 '
|
||||
+ '🤟 🤘 🤙 👈 👉 👆 👇 👍 👎 ✊ 👊 👏 🙌 👐 🤲 🙏 💪 🧠 👀 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ❣️ 💕 💖 💗 💘 💝 🔥 ✨ ⭐ '
|
||||
+ '🌟 🎉 🎊 🎈 🎁 🏆 🥇 🎮 🎧 🎵 🎶 📣 📢 ✅ ❌ ❓ ❗ ⚡ 🌈 ☀️ 🌙 🍕 🍔 🍿 ☕ 🍺 🚀 👑 💎 💰 🕶️ 🫡 🫶').split(' ');
|
||||
// The symbols page ('sym:<char>' keys): the digits row stays; then symbols, with the
|
||||
// language's currency in the currency spot; and a last row of up to 13: the language's
|
||||
// accented letters and the other currencies (keyboard-options.js).
|
||||
@@ -51,7 +71,7 @@
|
||||
[[null, 0.75], ...sym(['!', '@', '#', currency, '%', '^', '&', '*', '(', ')', '_', '+', '|']), [null, 1.25]],
|
||||
[[null, 1], ...sym('~ { } < > : ; " \' ? /'.split(' ')), ['Enter', 3]],
|
||||
[[null, (UNITS - Math.min(13, last.length)) / 2], ...sym(last.slice(0, 13)), [null, (UNITS - Math.min(13, last.length)) / 2]],
|
||||
[['Symbols', 2.5], ['Space', 10], ['Hide', 2.5]],
|
||||
BOTTOM,
|
||||
];
|
||||
|
||||
function create() {
|
||||
@@ -59,12 +79,24 @@
|
||||
canvas.width = W; canvas.height = H;
|
||||
const g = canvas.getContext('2d');
|
||||
let layout = ns.vrKeyboardOptions.layout(); // { chars, shifted, currency, accents, locale, styleName, languageName, version }
|
||||
let page = 'letters', shift = 'off', lastShiftAt = 0; // shift: 'off' | 'once' | 'caps'
|
||||
let page = 'letters', shift = 'off', lastShiftAt = 0; // page: 'letters' | 'symbols' | 'emoji'; shift: 'off' | 'once' | 'caps'
|
||||
let ver = 0, regions = [], keys = []; // keys: this page's [{ n, code, x, y, w, h }]
|
||||
const bump = () => { ver++; };
|
||||
// The emojis: the panel's own ({ text, image, label }), then the standard ones; which page.
|
||||
let ownEmojis = [], ownKey = '', emojiPage = 0;
|
||||
const kit = ns.vrKit.createCardKit(g, bump, 240); // their pictures (a redraw when one arrives)
|
||||
const emojis = () => [...ownEmojis, ...STANDARD_EMOJIS.map(e => ({ text: e, label: e }))];
|
||||
const emojiPages = () => Math.max(1, Math.ceil(emojis().length / PER_EMOJI_PAGE));
|
||||
const emojiAt = n => emojis()[emojiPage * PER_EMOJI_PAGE + n] || null;
|
||||
function setEmojis(list) {
|
||||
const key = (list || []).map(e => e.text).join('\u0001');
|
||||
if (key === ownKey) return;
|
||||
ownKey = key; ownEmojis = (list || []).filter(e => e && e.text); emojiPage = 0; bump();
|
||||
}
|
||||
|
||||
// What a key types now (Shift applied), or '' for a key this layout doesn't have.
|
||||
function charOf(code) {
|
||||
if (code.startsWith('emo:')) { const e = emojiAt(+code.slice(4)); return e ? e.text : ''; }
|
||||
if (code.startsWith('sym:')) return code.slice(4);
|
||||
const c = layout.chars[code];
|
||||
if (!c) return '';
|
||||
@@ -80,6 +112,9 @@
|
||||
if (code === 'Shift') return '⇧';
|
||||
if (code === 'Space') return layout.languageName;
|
||||
if (code === 'Symbols') return page === 'letters' ? '?123' : 'ABC';
|
||||
if (code === 'Emoji') return '😀';
|
||||
if (code === 'EmojiPrev' || code === 'EmojiNext') return code;
|
||||
if (code.startsWith('emo:')) { const e = emojiAt(+code.slice(4)); return e ? e.label || e.text : ''; }
|
||||
if (code === 'Hide') return ns.i18n.t('kb.hide');
|
||||
return charOf(code);
|
||||
}
|
||||
@@ -113,7 +148,10 @@
|
||||
g.fillText(`${layout.styleName} · ${layout.languageName}`, SIDE + BODY_W - 28, HEADER_H / 2 + 4);
|
||||
// Keys
|
||||
const unit = (BODY_W - PAD * 2) / UNITS;
|
||||
(page === 'letters' ? LETTERS : symbols(layout.currency, layout.accents)).forEach((row, r) => {
|
||||
if (emojiPage >= emojiPages()) emojiPage = 0;
|
||||
const rows = page === 'letters' ? LETTERS : page === 'emoji'
|
||||
? emojiRows(Math.min(PER_EMOJI_PAGE, emojis().length - emojiPage * PER_EMOJI_PAGE)) : symbols(layout.currency, layout.accents);
|
||||
rows.forEach((row, r) => {
|
||||
let x = SIDE + PAD;
|
||||
const y = HEADER_H + r * ROW_H;
|
||||
for (const [code, w] of fitRow(row)) {
|
||||
@@ -151,10 +189,13 @@
|
||||
// Backspace, Shift and Hide are Material Symbols (Shift filled while on, with a bar under
|
||||
// it for caps lock); Enter is ↵ and its name. Pointed at: the white ring; pressed:
|
||||
// squarer corners and a 0.12 state layer.
|
||||
const ICONS = { Backspace: 'backspace', Shift: 'shift', Hide: 'keyboard_hide' };
|
||||
const ICONS = { Backspace: 'backspace', Shift: 'shift', Hide: 'keyboard_hide', EmojiPrev: 'chevron_left', EmojiNext: 'chevron_right' };
|
||||
function drawKey(k, label, state, id) {
|
||||
const { h: hv, p: pv } = ns.vrMotion.of(state, id); // pointed at / pressed, eased (motion.js)
|
||||
const special = !/^(Key|Digit|sym:|Backquote|Minus|Equal|Bracket|Backslash|Semicolon|Quote|Comma|Period|Slash|IntlBackslash|Space)/.test(k.code);
|
||||
const special = !/^(Key|Digit|sym:|emo:|Backquote|Minus|Equal|Bracket|Backslash|Semicolon|Quote|Comma|Period|Slash|IntlBackslash|Space)/.test(k.code);
|
||||
// (an emoji page's ‹ / › with no page that way: faded)
|
||||
const dim = (k.code === 'EmojiPrev' && emojiPage === 0) || (k.code === 'EmojiNext' && emojiPage >= emojiPages() - 1);
|
||||
if (dim) g.globalAlpha = 0.38;
|
||||
const lit = k.code === 'Enter' || (k.code === 'Shift' && shift !== 'off');
|
||||
const [bg, fg] = lit ? [P.brand, P.onBrand] : special ? [P.secondaryContainer, P.onSecondaryContainer] : [P.surfaceContainerHighest, P.onSurface];
|
||||
const rad = Math.max(3, ns.vrMotion.lerp(T.shape.large, T.shape.small, pv)); // squares up as it's pressed, springs back
|
||||
@@ -170,11 +211,17 @@
|
||||
const tw = g.measureText(label).width, x0 = cx - (34 + 10 + tw) / 2;
|
||||
ns.vrSymbols.draw(g, 'keyboard_return', x0 + 17, cy, 34, false);
|
||||
g.textAlign = 'left'; g.fillText(label, x0 + 44, cy + 2);
|
||||
} else if (k.code.startsWith('emo:') && (emojiAt(+k.code.slice(4)) || {}).image) {
|
||||
const img = kit.image(emojiAt(+k.code.slice(4)).image), s = 48; // a panel's own emoji: its picture
|
||||
if (img) kit.drawImageContain(img, cx - s / 2, cy - s / 2, s, s);
|
||||
else { g.fillStyle = PA('onSurface', 0.12); roundRect(g, cx - s / 2, cy - s / 2, s, s, 10); g.fill(); }
|
||||
} else {
|
||||
g.font = k.code === 'Space' ? T.font('labelLarge', 22) : special ? T.font('labelLarge', 26) : `36px ${FONT}`;
|
||||
g.font = k.code === 'Space' ? T.font('labelLarge', 22) : k.code === 'Emoji' || k.code.startsWith('emo:') ? `40px ${FONT}`
|
||||
: special ? T.font('labelLarge', 26) : `36px ${FONT}`;
|
||||
g.fillText(label, cx, cy + 2);
|
||||
}
|
||||
ns.vrKit.hoverRing(g, k.x, k.y, k.w, k.h, rad, hv);
|
||||
g.globalAlpha = 1;
|
||||
}
|
||||
|
||||
const keyOf = id => (id && id.startsWith('key:') ? keys[+id.slice(4)] : null);
|
||||
@@ -185,6 +232,10 @@
|
||||
if (!k) return null;
|
||||
if (k.code === 'Hide') return { hide: true };
|
||||
if (k.code === 'Symbols') { page = page === 'letters' ? 'symbols' : 'letters'; shift = 'off'; bump(); return null; }
|
||||
if (k.code === 'Emoji') { page = 'emoji'; shift = 'off'; bump(); return null; }
|
||||
if (k.code === 'EmojiPrev' || k.code === 'EmojiNext') {
|
||||
emojiPage = Math.max(0, Math.min(emojiPages() - 1, emojiPage + (k.code === 'EmojiNext' ? 1 : -1))); bump(); return null;
|
||||
}
|
||||
if (k.code === 'Shift') {
|
||||
const now = performance.now();
|
||||
shift = shift === 'off' ? (now - lastShiftAt < 400 ? 'caps' : 'once') : shift === 'once' && now - lastShiftAt < 400 ? 'caps' : 'off';
|
||||
@@ -197,7 +248,7 @@
|
||||
return c ? { key: c } : null;
|
||||
}
|
||||
|
||||
const repeats = id => { const k = keyOf(id); return !!k && !/^(Shift|Symbols|Hide|Enter)$/.test(k.code); };
|
||||
const repeats = id => { const k = keyOf(id); return !!k && !/^(Shift|Symbols|Emoji|EmojiPrev|EmojiNext|Hide|Enter|emo:)/.test(k.code); };
|
||||
const hitTest = (u, v) => {
|
||||
const x = u * W, y = v * H;
|
||||
for (const r of regions) if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) return { id: r.id };
|
||||
@@ -208,7 +259,7 @@
|
||||
return (x >= SIDE && x <= SIDE + BODY_W && y <= BODY_H) || !!hitTest(u, v);
|
||||
};
|
||||
|
||||
return { canvas, aspect: H / W, width: W, height: H, draw, hitTest, solidAt, press, repeats, animating: motion.moving, version: () => `${ver}.${ns.vrKeyboardOptions.layout().version}` }; // its own changes, and the layout's
|
||||
return { canvas, aspect: H / W, width: W, height: H, draw, hitTest, solidAt, press, repeats, setEmojis, animating: motion.moving, version: () => `${ver}.${ns.vrKeyboardOptions.layout().version}` }; // its own changes, and the layout's
|
||||
}
|
||||
|
||||
ns.vrKeyboard = { create };
|
||||
|
||||
@@ -8,14 +8,20 @@
|
||||
// back when the laser leaves it. Everything outside the bar and the grown pills is see-through
|
||||
// (solidAt), so the laser reaches the panel behind.
|
||||
//
|
||||
// const tb = ns.vrOrbitersUI.createToolbar(items, grows); items: [{ id, label, icon }]
|
||||
// const tb = ns.vrOrbitersUI.createToolbar(items, grows, opts); items: [{ id, label, icon }]
|
||||
// (the id is also the control id; label: text or a function giving it; icon: a
|
||||
// Material Symbols name, symbols.js); grows: 'left' or 'right', the side the names
|
||||
// grow towards (away from the video)
|
||||
// tb.draw({ open: { <item id>: bool }, hover, pressed, now (ms) })
|
||||
// grow towards (away from the video); opts.handle: { id }, a grab bar beside the bar on the
|
||||
// video side (an upright pill with a drag handle, as the keyboard's bar, lying down there;
|
||||
// the handle in the brand colour while `held`, the white ring while pointed at; its
|
||||
// clickable zone wider than the pill)
|
||||
// tb.draw({ open: { <item id>: bool }, hover, pressed, held (the handle's id while it's held), now (ms) })
|
||||
// tb.setHidden({ <item id>: true }) items left out (the bar shrinks to the rest, from its
|
||||
// top; the canvas keeps room for them all); returns whether that changed anything
|
||||
// tb.hitTest(u, v) -> { id } | null; tb.solidAt(u, v); tb.moving() (keep redrawing)
|
||||
// tb.barCentre: the bar's centre in canvas pixels { x, y }, tb.barWidth (px) (the bar isn't
|
||||
// centred in the canvas: the room for the names is on one side)
|
||||
// tb.barCentre: the bar's centre in canvas pixels { x, y } (kept up to date by setHidden),
|
||||
// tb.barWidth (px) (the bar isn't centred in the canvas: the room for the names is on one
|
||||
// side), tb.handleRoom (px: the room the grab bar takes beside the bar, on the video side)
|
||||
//
|
||||
// The pill: one M3 tonal button (icon + name), filled while its panel is open; e.g. Settings
|
||||
// on the controls panel's top edge.
|
||||
@@ -48,14 +54,39 @@
|
||||
return `rgb(${A.map((v, i) => Math.round(v + (B[i] - v) * t)).join(',')})`;
|
||||
}
|
||||
|
||||
function createToolbar(items, grows) {
|
||||
function createToolbar(panelItems, grows, opts = {}) {
|
||||
const BAR_W = 104, PAD = 8, GAP = 6, ITEM = BAR_W - PAD * 2; // the bar's width; an item is ITEM px square
|
||||
const ICON = 40, LABEL_GAP = 14, END = 30; // a grown pill: icon, gap, room after the name
|
||||
const ROOM = 300, MARGIN = 10; // the most a name can grow; room for the ring
|
||||
const BAR_H = PAD * 2 + items.length * ITEM + (items.length - 1) * GAP;
|
||||
const TW = MARGIN * 2 + BAR_W + ROOM, TH = MARGIN * 2 + BAR_H;
|
||||
// The grab bar: an upright pill GRAB.w wide (at most GRAB.maxH tall, GRAB.inset shorter than
|
||||
// the bar), GRAB.gap from the bar on the video side; its clickable zone GRAB.reach wider on
|
||||
// each side and above / below.
|
||||
const GRAB = { w: 30, gap: 10, maxH: 240, inset: 48, reach: 14 };
|
||||
const handle = opts.handle || null;
|
||||
const HANDLE_ROOM = handle ? GRAB.gap + GRAB.w + GRAB.reach : 0;
|
||||
const items = panelItems;
|
||||
const heightFor = n => PAD * 2 + n * ITEM + Math.max(0, n - 1) * GAP; // the bar with n items
|
||||
const TW = MARGIN * 2 + BAR_W + ROOM + HANDLE_ROOM, TH = MARGIN * 2 + heightFor(items.length);
|
||||
const left = grows === 'left';
|
||||
const barX = left ? TW - MARGIN - BAR_W : MARGIN, barY = MARGIN;
|
||||
// (the names grow away from the video; the grab bar is on the video side)
|
||||
const barX = left ? TW - MARGIN - HANDLE_ROOM - BAR_W : MARGIN + HANDLE_ROOM, barY = MARGIN;
|
||||
let shownItems = items, BAR_H = heightFor(items.length), hiddenKey = '';
|
||||
const barCentre = { x: barX + BAR_W / 2, y: barY + BAR_H / 2 };
|
||||
function setHidden(hidden = {}) {
|
||||
const key = items.filter(it => hidden[it.id]).map(it => it.id).join(',');
|
||||
if (key === hiddenKey) return false;
|
||||
hiddenKey = key;
|
||||
shownItems = items.filter(it => !hidden[it.id]);
|
||||
BAR_H = heightFor(shownItems.length);
|
||||
barCentre.y = barY + BAR_H / 2;
|
||||
return true;
|
||||
}
|
||||
// Where the grab bar is now (it follows the bar's height): { x, y, w, h }, or null.
|
||||
function grabRect() {
|
||||
if (!handle) return null;
|
||||
const h = Math.max(60, Math.min(GRAB.maxH, BAR_H - GRAB.inset));
|
||||
return { x: left ? barX + BAR_W + GRAB.gap : barX - GRAB.gap - GRAB.w, y: barY + (BAR_H - h) / 2, w: GRAB.w, h };
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = TW; canvas.height = TH;
|
||||
const g = canvas.getContext('2d');
|
||||
@@ -82,10 +113,21 @@
|
||||
}
|
||||
|
||||
g.clearRect(0, 0, TW, TH);
|
||||
bodies = [];
|
||||
// The grab bar (as the keyboard's, upright): the panels' background, a drag handle (the
|
||||
// brand colour while held), the white ring while pointed at or held.
|
||||
const gr = grabRect();
|
||||
if (gr) {
|
||||
const hot = state.hover === handle.id, heldNow = state.held === handle.id;
|
||||
g.fillStyle = T.panelFill(); roundRect(g, gr.x, gr.y, gr.w, gr.h, gr.w / 2); g.fill();
|
||||
g.fillStyle = heldNow ? P.brand : P.onSurfaceVariant; roundRect(g, gr.x + gr.w / 2 - 2.5, gr.y + gr.h / 2 - 32, 5, 64, 2.5); g.fill();
|
||||
if (hot || heldNow) hoverRing(g, gr.x, gr.y, gr.w, gr.h, gr.w / 2);
|
||||
}
|
||||
if (!shownItems.length) return;
|
||||
// The bar: the panels' glass tint while its side has no panel open; solid while one is
|
||||
// (it stands over that panel's edge, which mustn't show through), fading as it opens / closes.
|
||||
// (the sum, so switching panels on that side, one fading out as the other fades in, stays solid)
|
||||
const solid = Math.min(1, items.reduce((a, it) => a + sel.get(it.id), 0));
|
||||
const solid = Math.min(1, shownItems.reduce((a, it) => a + sel.get(it.id), 0));
|
||||
g.fillStyle = T.panelFill();
|
||||
roundRect(g, barX, barY, BAR_W, BAR_H, BAR_W / 2); g.fill();
|
||||
if (solid > 0.01) {
|
||||
@@ -93,7 +135,7 @@
|
||||
roundRect(g, barX, barY, BAR_W, BAR_H, BAR_W / 2); g.fill(); g.globalAlpha = 1;
|
||||
}
|
||||
g.font = T.font('labelLarge', 26);
|
||||
bodies = items.map((it, i) => {
|
||||
bodies = shownItems.map((it, i) => {
|
||||
const label = labelText(it), e = 1 - Math.pow(1 - grow.get(it.id), 3), s = smooth(sel.get(it.id));
|
||||
const room = Math.min(ROOM, LABEL_GAP + g.measureText(label).width + END) * e;
|
||||
const y = barY + PAD + i * (ITEM + GAP), x = barX + PAD;
|
||||
@@ -126,9 +168,10 @@
|
||||
|
||||
// Which item is at (u, v) (0..1 from the top-left), as last drawn? Anywhere on the bar
|
||||
// counts (the nearest item, so the laser doesn't leave the toolbar between items), and
|
||||
// so does a grown pill. -> { id } or null.
|
||||
// so does a grown pill, and the grab bar (with room around it). -> { id } or null.
|
||||
function hitTest(u, v) {
|
||||
const px = u * TW, py = v * TH;
|
||||
const px = u * TW, py = v * TH, gr = grabRect();
|
||||
if (gr && px >= gr.x - GRAB.reach && px <= gr.x + gr.w + GRAB.reach && py >= gr.y - GRAB.reach && py <= gr.y + gr.h + GRAB.reach) return { id: handle.id };
|
||||
for (const b of bodies) if (b.e > 0.3 && px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + ITEM) return { id: b.id };
|
||||
if (!bodies.length || px < barX || px > barX + BAR_W || py < barY || py > barY + BAR_H) return null;
|
||||
let best = null, bestD = Infinity;
|
||||
@@ -139,7 +182,7 @@
|
||||
const solidAt = (u, v) => !!hitTest(u, v);
|
||||
// Still animating? (A name growing or shrinking, or a selection fading.)
|
||||
const moving = () => items.some(it => grow.get(it.id) !== growGoal.get(it.id) || sel.get(it.id) !== selGoal.get(it.id));
|
||||
return { canvas, aspect: TH / TW, draw, hitTest, solidAt, moving, barCentre: { x: barX + BAR_W / 2, y: barY + BAR_H / 2 }, barWidth: BAR_W };
|
||||
return { canvas, aspect: TH / TW, draw, setHidden, hitTest, solidAt, moving, barCentre, barWidth: BAR_W, handleRoom: HANDLE_ROOM };
|
||||
}
|
||||
|
||||
function createPill(item) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
// queue: null | { hasPrev, hasNext } (in a list: Previous / Next at
|
||||
// the seek bar's ends, ids pl:prev / pl:next) },
|
||||
// quality: { current, currentLabel, levels, icons (optional: Material Symbols names
|
||||
// shown on its button instead of icon + name: one, or two stacked) },
|
||||
// shown on its button instead of icon + name: one, or two stacked; each
|
||||
// level can have its own, shown in the Quality menu: popups-ui.js) },
|
||||
// format: { id, label }, speed,
|
||||
// volume: { level, muted }, chapters: [{ title, start }],
|
||||
// heatmap: [{ start, duration, intensity (0..1) }] (how much each part is rewatched:
|
||||
@@ -39,7 +40,11 @@
|
||||
// "Skip on desktop" without the Skip Helper extension),
|
||||
// extras: { left: [...], right: [...] } (the site's own buttons, at the inner ends
|
||||
// of the outer parts of the button row: { id, icon, label (under the
|
||||
// icon), on (icon filled), disabled }; ids 'x:<id>'),
|
||||
// icon), on (icon filled), disabled, icons (optional: one or two icons, e.g.
|
||||
// { name, color } ones, stacked in place of icon + label, like Quality's) },
|
||||
// and seek: null | { id, label, icon, mark (an icon spec on its left, e.g.
|
||||
// a coloured one), disabled } (one button at the seek bar's right end, where
|
||||
// Skip ad goes during ads; the bar shortens for it); ids 'x:<id>'),
|
||||
// menuOpen: null | 'quality' | 'speed' | 'format' | 'volume' | 'x:<id>' (that button
|
||||
// is shown selected: its menu floats in front of the panel, popups-ui.js) }
|
||||
// hover/pressed: a control id or null. Fixed ids: seek, exit, quality, speed, back10,
|
||||
@@ -70,9 +75,11 @@
|
||||
};
|
||||
// The button row: one M3E button group, in three parts ([Exit, Quality, Speed, the site's
|
||||
// left extras] [-10 s, play / pause, +10 s] [the site's right extras, Volume, Format,
|
||||
// Recenter]), centred (room for one extra a side). Buttons are BTN.w x
|
||||
// BTN.h, play / pause is bigger; GAP between buttons, SEP between the parts.
|
||||
// Recenter]), centred (room for one extra a side at full width; with more, the buttons
|
||||
// narrow to stay inside ROW_MARGIN). Buttons are BTN.w x BTN.h, play / pause is bigger;
|
||||
// GAP between buttons, SEP between the parts.
|
||||
const BTN = { w: 88, h: 88, y: ROW_Y + 2 }, PLAY = { w: 132, h: 104, y: ROW_Y - 6 }, GAP = 8, SEP = 20;
|
||||
const ROW_MARGIN = 48; // the button row keeps this far from the panel's sides
|
||||
// In a playlist: Previous / Next video buttons at the two ends of the seek row, and the
|
||||
// seek bar shortened between them.
|
||||
const PLAYLIST = {
|
||||
@@ -90,7 +97,7 @@
|
||||
const tr = (key, vars) => ns.i18n.t(key, vars); // words in the interface language (i18n.js)
|
||||
|
||||
// Which icon a format gets on the Format button.
|
||||
const FORMAT_ICONS = { flat: 'theaters', '360mono': '360', '360eac': 'view_in_ar', '180mono': 'panorama_photosphere' };
|
||||
const FORMAT_ICONS = { flat: '2d_2', flatsbs: '3d_2', '360mono': '360', '360eac': 'view_in_ar', '180mono': 'panorama_photosphere', '180sbs': '3d_rotation' };
|
||||
// The speaker for a volume (0-100) / muted.
|
||||
const volumeIcon = v => (v.muted ? 'volume_off' : v.level <= 0 ? 'volume_mute' : v.level < 50 ? 'volume_down' : 'volume_up');
|
||||
|
||||
@@ -228,8 +235,8 @@
|
||||
// An icon over a small label (the button row's buttons), or the icon alone, centred.
|
||||
// One icon big, or two stacked (the first smaller and higher, e.g. 4K over 60 fps).
|
||||
function iconStack(icons) {
|
||||
if (icons.length < 2) { S.draw(g, icons[0], 0, 0, 48, false); return; }
|
||||
S.draw(g, icons[0], 0, -14, 38, false); S.draw(g, icons[1], 0, 20, 30, false);
|
||||
if (icons.length < 2) { S.drawIcon(g, icons[0], 0, 0, 48, false); return; }
|
||||
S.drawIcon(g, icons[0], 0, -14, 38, false); S.drawIcon(g, icons[1], 0, 20, 30, false);
|
||||
}
|
||||
function iconLabel(icon, text, filled = false) {
|
||||
if (text) { S.draw(g, icon, 0, -13, 36, filled); label(text, 'labelMedium', 23); }
|
||||
@@ -317,9 +324,6 @@
|
||||
// The button row (see BTN): each shows its icon and, under it, its current value.
|
||||
function drawTransport(state) {
|
||||
const v = state.volume || { level: 100, muted: false };
|
||||
// The format's short name, first line only (a two-line name would be too small; the
|
||||
// icon tells the formats apart).
|
||||
const fmtLabel = ((state.format && state.format.label) || '—').split('\n')[0];
|
||||
// The site's own buttons (state.extras): [id, icon, label, extra].
|
||||
const extras = side => ((state.extras && state.extras[side]) || []).map(x => [`x:${x.id}`, x.icon, x.label || '', x]);
|
||||
const items = [
|
||||
@@ -329,10 +333,15 @@
|
||||
['back10', 'replay_10', ''], ['play', state.paused ? 'play_arrow' : 'pause', ''], ['fwd10', 'forward_10', ''], null,
|
||||
...extras('right'),
|
||||
['volume', volumeIcon(v), v.muted ? tr('panel.muted') : `${Math.round(v.level)}%`],
|
||||
['format', FORMAT_ICONS[state.format && state.format.id] || 'theaters', fmtLabel],
|
||||
['format', formatIcon(state.format && state.format.id), tr('panel.format')], // (the icon tells the formats apart)
|
||||
['recenter', 'recenter', tr('panel.recenter')],
|
||||
];
|
||||
const width = it => (it[0] === 'play' ? PLAY.w : BTN.w);
|
||||
// Buttons narrow (from BTN.w) when a site's extra buttons would push the row past the
|
||||
// panel's side margins; heights stay.
|
||||
const n = items.filter(it => it && it[0] !== 'play').length, seps = items.filter(it => !it).length;
|
||||
const gaps = (items.length - seps - 1) * GAP + seps * (SEP - GAP);
|
||||
const bw = Math.min(BTN.w, Math.floor((W - ROW_MARGIN * 2 - PLAY.w - gaps) / Math.max(1, n)));
|
||||
const width = it => (it[0] === 'play' ? PLAY.w : bw);
|
||||
const total = items.reduce((s, it) => s + (it ? width(it) + GAP : SEP - GAP), -GAP);
|
||||
let x = (W - total) / 2;
|
||||
for (const it of items) {
|
||||
@@ -344,8 +353,9 @@
|
||||
} else {
|
||||
// the button whose menu is open shows selected (its menu floats in front: popups-ui.js);
|
||||
// an extra that's `on` (e.g. captions showing) has its icon filled
|
||||
const qi = id === 'quality' && state.quality && state.quality.icons; // the quality as icons (its full name is in its menu)
|
||||
button({ x, y: BTN.y, w: BTN.w, h: BTN.h }, id, state, () => (qi && qi.length ? iconStack(qi) : iconLabel(icon, text, !!(extra && extra.on))),
|
||||
// the quality as icons (its full name is in its menu), or a site button's own icons
|
||||
const qi = id === 'quality' ? state.quality && state.quality.icons : extra && extra.icons;
|
||||
button({ x, y: BTN.y, w: bw, h: BTN.h }, id, state, () => (qi && qi.length ? iconStack(qi) : iconLabel(icon, text, !!(extra && extra.on))),
|
||||
{ kind: state.menuOpen === id ? 'selected' : 'tonal', disabled: !!(extra && extra.disabled) });
|
||||
}
|
||||
x += width(it) + GAP;
|
||||
@@ -407,6 +417,16 @@
|
||||
regions = [];
|
||||
const queue = state.nowPlaying && state.nowPlaying.queue;
|
||||
seekRect = queue && !state.ad ? PLAYLIST.seek : FIXED.seek;
|
||||
// The site's seek-row button (state.extras.seek, e.g. Jump ahead), where Skip ad goes
|
||||
// during ads: at the seek bar's right end (before a playlist's next button), the bar
|
||||
// shortened to make room.
|
||||
const sa = !state.ad && state.extras && state.extras.seek;
|
||||
let saRect = null;
|
||||
if (sa) {
|
||||
const right = queue ? PLAYLIST.next.x - 16 : seekRect.x + seekRect.w;
|
||||
saRect = { x: right - FIXED.skipad.w, y: FIXED.skipad.y, w: FIXED.skipad.w, h: FIXED.skipad.h };
|
||||
seekRect = { ...seekRect, w: saRect.x - 24 - seekRect.x };
|
||||
}
|
||||
g.clearRect(0, 0, W, H);
|
||||
g.fillStyle = bodyFill();
|
||||
roundRect(g, 0, 0, W, BODY_H, T.shape.extraLarge); g.fill();
|
||||
@@ -460,6 +480,7 @@
|
||||
else {
|
||||
drawSeekBar(state, shown);
|
||||
if (queue) drawPlaylistButtons(queue, state);
|
||||
if (sa) drawSeekAction(sa, saRect, state);
|
||||
}
|
||||
|
||||
drawTransport(state); // the button row
|
||||
@@ -502,6 +523,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The site's seek-row button (id 'x:<id>', like its other extras), shaped like Skip ad:
|
||||
// its mark (an icon, e.g. the Diamond in its own colour) on the left, the label, its icon
|
||||
// on the right. Greyed out when disabled.
|
||||
function drawSeekAction(x, r, state) {
|
||||
button(r, `x:${x.id}`, state, () => {
|
||||
const left = x.mark ? 36 : 0;
|
||||
if (x.mark) S.drawIcon(g, x.mark, -r.w / 2 + 30, 0, 26, true);
|
||||
const t = ns.vrKit.fitFont(g, x.label || '', r.w - 84 - left, 24, '500 ');
|
||||
g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(t, -20 + left / 2, 1);
|
||||
if (x.icon) S.draw(g, x.icon, r.w / 2 - 34, 0, 30, true);
|
||||
}, { disabled: !!x.disabled });
|
||||
}
|
||||
|
||||
// A live stream, in place of the time (top right): a "● LIVE" chip in the brand colour at
|
||||
// the live moment. Behind it (rewound, paused, or while scrubbing) the chip turns tonal,
|
||||
// with how far behind in front ("−0:45"), and clicking it (id 'live') jumps back to the
|
||||
@@ -623,5 +657,8 @@
|
||||
titleMoving: () => titleMoving, animating: motion.moving };
|
||||
}
|
||||
|
||||
ns.vrPanelUI = { createPanelUI, formatTime: fmt, speedName };
|
||||
// The Format button's icon for a format id (also each row's icon in the Format menu: vr-panel.js).
|
||||
const formatIcon = id => FORMAT_ICONS[id] || '2d_2';
|
||||
|
||||
ns.vrPanelUI = { createPanelUI, formatTime: fmt, speedName, formatIcon };
|
||||
})(globalThis.__streamxr = globalThis.__streamxr || {});
|
||||
@@ -10,7 +10,10 @@
|
||||
// right above its button.
|
||||
// const menu = ns.vrPopupsUI.createMenu();
|
||||
// menu.draw(m, { hover, pressed }) m: { items: [{ id, text ("1080p" or "1080p\nPremium"),
|
||||
// on (the current one: ticked), disabled }], note (a grey line above the items), scroll
|
||||
// on (the current one: ticked), disabled, icons (optional: Material Symbols names, or
|
||||
// { name, color } for one in its own colour (symbols.js drawIcon), drawn
|
||||
// on the row's left, e.g. ['4k', '60fps']; the tick then moves to the right end) }],
|
||||
// note (a grey line above the items), scroll
|
||||
// (a long list scrolls: see drawList), key, width }
|
||||
// or { volume: { level (0-100), muted } }; ids: the items', or 'vol:mute' / 'vol:slider'
|
||||
// menu.hitTest(u, v) -> { id, fraction? } | null; menu.solidAt(u, v); menu.moving()
|
||||
@@ -22,8 +25,9 @@
|
||||
// The dialog: a title, an optional line of text and two buttons (cancel, confirm) in an M3
|
||||
// dialog card; the scrim (the dim layer behind it) is drawn by vr-panel.js.
|
||||
// const dlg = ns.vrPopupsUI.createDialog();
|
||||
// dlg.draw(d, { hover, pressed }) d: { title, text, cancel, confirm, danger (the confirm
|
||||
// button in the error colour) }; ids 'dialog:cancel', 'dialog:confirm'
|
||||
// dlg.draw(d, { hover, pressed }) d: { title, text (up to four lines: the card grows to fit), cancel (null: no
|
||||
// cancel button, for a dialog that only informs), confirm, danger (the confirm button in
|
||||
// the error colour) }; ids 'dialog:cancel', 'dialog:confirm'
|
||||
// dlg.hitTest(u, v) -> { id } | null; dlg.solidAt(u, v) (on the card); dlg.moving()
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
@@ -37,6 +41,7 @@
|
||||
const W = 680, H = 760; // room for the widest (volume) and tallest (a long list)
|
||||
const LIST_W = 360, ROW_H = 64, MIN_ROW_H = 40, PAD = 8, NOTE_H = 48; // rows shrink (to MIN_ROW_H) so a long list fits
|
||||
const SCROLL_ROWS = 8; // a scrolling list shows this many rows
|
||||
const ICON = 32, ICON_STEP = 36, ICON_LEFT = 20; // items' leading icons: size, spacing, left margin (px)
|
||||
const VOL = { w: 640, h: 112 };
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W; canvas.height = H;
|
||||
@@ -70,8 +75,13 @@
|
||||
// (scrollBy, the laser's stick or a trigger drag: vr-panel.js), a thin bar on its right
|
||||
// saying where; m.key: a new one starts it at the top. m.width: the card's width (px;
|
||||
// default LIST_W).
|
||||
// Items with `icons` get them on the left, in a column as wide as the most icons in any
|
||||
// row (so the names line up); the card grows by that much, and the tick goes to the right.
|
||||
function drawList(m, ui) {
|
||||
const items = m.items || [], listW = Math.min(W, m.width || LIST_W);
|
||||
const items = m.items || [];
|
||||
const lead = Math.max(0, ...items.map(it => (it.icons || []).length));
|
||||
const textX = lead ? ICON_LEFT + lead * ICON_STEP + 12 : 64; // where the names start in a row
|
||||
const listW = Math.min(W, (m.width || LIST_W) + (lead ? textX - 64 + 40 : 0));
|
||||
const noteH = m.note ? NOTE_H : 0;
|
||||
const rowH = m.scroll ? ROW_H : Math.max(MIN_ROW_H, Math.min(ROW_H, Math.floor((H - PAD * 2 - noteH) / Math.max(1, items.length))));
|
||||
const shownRows = m.scroll ? Math.min(items.length, SCROLL_ROWS) : items.length;
|
||||
@@ -87,7 +97,11 @@
|
||||
list = { x: card.x, y: card.y + PAD + noteH, w: listW, h: shownRows * rowH };
|
||||
maxScroll = m.scroll ? Math.max(0, items.length * rowH - list.h) : 0;
|
||||
scroll = Math.min(maxScroll, Math.max(0, scroll));
|
||||
g.save(); g.beginPath(); g.rect(list.x, list.y, list.w, list.h); g.clip();
|
||||
// Clipped to the rows, plus the card's padding when nothing scrolls (so the hover ring
|
||||
// round the first and last rows, drawn just outside them, isn't cut).
|
||||
const bleed = maxScroll ? 0 : PAD;
|
||||
g.save(); g.beginPath(); g.rect(list.x, list.y - bleed, list.w, list.h + bleed * 2); g.clip();
|
||||
const rings = []; // [row, radius, amount] to ring after the rows
|
||||
items.forEach((it, i) => {
|
||||
const r = { x: card.x + PAD, y: list.y + i * rowH - scroll, w: listW - PAD * 2 - (maxScroll ? 12 : 0), h: rowH };
|
||||
if (r.y + r.h < list.y || r.y > list.y + list.h) return; // scrolled out of view
|
||||
@@ -101,15 +115,18 @@
|
||||
g.globalAlpha = it.disabled ? 0.38 : 1;
|
||||
const fg = it.on ? P.onSecondaryContainer : P.onSurface;
|
||||
g.fillStyle = fg;
|
||||
if (it.on) S.draw(g, 'check', r.x + 32, r.y + r.h / 2, 28, false);
|
||||
const lines = String(it.text).split('\n');
|
||||
if (it.on) S.draw(g, 'check', lead ? r.x + r.w - 30 : r.x + 32, r.y + r.h / 2, 28, false);
|
||||
(it.icons || []).forEach((icon, k) => S.drawIcon(g, icon, r.x + ICON_LEFT + ICON_STEP * k + ICON_STEP / 2, r.y + r.h / 2, ICON, false));
|
||||
const lines = String(it.text).split('\n'), textW = r.w - textX - (lead ? 52 : 12);
|
||||
g.textAlign = 'left'; g.textBaseline = 'middle';
|
||||
g.font = T.font('labelLarge', 24);
|
||||
g.fillText(clip(lines[0], r.w - 76), r.x + 64, r.y + r.h / 2 + (lines.length > 1 ? -11 : 1));
|
||||
if (lines.length > 1) { g.font = T.font('labelMedium', 17); g.fillStyle = it.on ? fg : P.onSurfaceVariant; g.fillText(clip(lines.slice(1).join(' '), r.w - 76), r.x + 64, r.y + r.h / 2 + 15); }
|
||||
g.fillText(clip(lines[0], textW), r.x + textX, r.y + r.h / 2 + (lines.length > 1 ? -11 : 1));
|
||||
if (lines.length > 1) { g.font = T.font('labelMedium', 17); g.fillStyle = it.on ? fg : P.onSurfaceVariant; g.fillText(clip(lines.slice(1).join(' '), textW), r.x + textX, r.y + r.h / 2 + 15); }
|
||||
g.globalAlpha = 1;
|
||||
hoverRing(g, r.x, r.y, r.w, r.h, rad, hv);
|
||||
if (hv > 0.01) rings.push([r, rad, hv]);
|
||||
});
|
||||
// The pointing rings last, so a neighbouring row (e.g. the ticked one's fill) can't cover one.
|
||||
for (const [r, rad, hv] of rings) hoverRing(g, r.x, r.y, r.w, r.h, rad, hv);
|
||||
g.restore();
|
||||
if (maxScroll) { // the scroll bar: where the view is in the list
|
||||
const th = Math.max(36, list.h * list.h / (list.h + maxScroll)), ty = list.y + (list.h - th) * scroll / maxScroll;
|
||||
@@ -163,7 +180,9 @@
|
||||
}
|
||||
|
||||
function createDialog() {
|
||||
const W = 720, H = 220, PAD = 32, BTN_H = 56; // the title, a line of text, the buttons
|
||||
const W = 720, PAD = 32, BTN_H = 56, LINE = 30, MAX_LINES = 4; // the title, up to four lines of text, the buttons
|
||||
const TEXT_TOP = PAD + 50; // where the text starts in the card
|
||||
const H = TEXT_TOP + MAX_LINES * LINE + 20 + BTN_H + PAD; // room for the tallest card
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W; canvas.height = H;
|
||||
const g = canvas.getContext('2d');
|
||||
@@ -172,21 +191,27 @@
|
||||
|
||||
// An M3 dialog: the panels' glass background, extra-large corners; the title (headline), the
|
||||
// text, then the buttons at the bottom right: cancel (tonal) and confirm (filled, or
|
||||
// the error colour for something that can't be undone).
|
||||
// the error colour for something that can't be undone). The card is as tall as its text
|
||||
// needs, centred in the canvas (the rest see-through).
|
||||
function draw(d, ui) {
|
||||
ui = motion.step(ui);
|
||||
regions = [];
|
||||
g.clearRect(0, 0, W, H);
|
||||
g.fillStyle = T.panelFill(); roundRect(g, 0, 0, W, H, T.shape.extraLarge); g.fill(); // the panels' glass (or solid)
|
||||
g.font = T.font('bodyMedium', 22);
|
||||
const lines = d.text ? ns.vrKit.wrap(g, d.text, W - PAD * 2, MAX_LINES) : [];
|
||||
const cardH = (lines.length ? TEXT_TOP + lines.length * LINE : TEXT_TOP - 10) + 20 + BTN_H + PAD;
|
||||
const y0 = Math.round((H - cardH) / 2);
|
||||
g.fillStyle = T.panelFill(); roundRect(g, 0, y0, W, cardH, T.shape.extraLarge); g.fill(); // the panels' glass (or solid)
|
||||
g.fillStyle = P.onSurface; g.textAlign = 'left'; g.textBaseline = 'top';
|
||||
g.fillText(fitFont(g, d.title || '', W - PAD * 2, 32, '500 '), PAD, PAD);
|
||||
if (d.text) { g.fillStyle = P.onSurfaceVariant; g.fillText(fitFont(g, d.text, W - PAD * 2, 22), PAD, PAD + 50); }
|
||||
g.fillText(fitFont(g, d.title || '', W - PAD * 2, 32, '500 '), PAD, y0 + PAD);
|
||||
g.fillStyle = P.onSurfaceVariant; g.font = T.font('bodyMedium', 22);
|
||||
lines.forEach((line, i) => g.fillText(line, PAD, y0 + TEXT_TOP + i * LINE));
|
||||
g.font = T.font('labelLarge', 22);
|
||||
const bw = t => Math.max(140, g.measureText(t).width + 56);
|
||||
const cw = bw(d.confirm || ''), kw = bw(d.cancel || '');
|
||||
const by = H - PAD - BTN_H;
|
||||
const by = y0 + cardH - PAD - BTN_H;
|
||||
button(g, regions, 'dialog:confirm', W - PAD - cw, by, cw, BTN_H, { label: d.confirm, kind: d.danger ? 'danger' : 'filled' }, ui);
|
||||
button(g, regions, 'dialog:cancel', W - PAD - cw - 16 - kw, by, kw, BTN_H, { label: d.cancel, kind: 'tonal' }, ui);
|
||||
if (d.cancel != null) button(g, regions, 'dialog:cancel', W - PAD - cw - 16 - kw, by, kw, BTN_H, { label: d.cancel, kind: 'tonal' }, ui);
|
||||
}
|
||||
function hitTest(u, v) { const x = u * W, y = v * H; const r = regions.find(q => inside(q, x, y)); return r ? { id: r.id } : null; }
|
||||
return { canvas, aspect: H / W, draw, hitTest, solidAt: () => true, moving: motion.moving };
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
// The "Safe zone" settings tab, which belongs to the shell (the layout is the shell's,
|
||||
// whatever the site): three sliders for the Safe Zone (safe-zone.js), each with a Default
|
||||
// button: its width and height as shares of what the headset shows, and its centre above or
|
||||
// below eye level. While the tab is open, an outline shows the zone around you. A site's
|
||||
// Options panel includes it as one of its tabs.
|
||||
// below eye level; and a switch, "Recenter resets panels": whether recentering also puts the
|
||||
// panels you moved back in their places (off: they come along, where you put them; remembered).
|
||||
// While the tab is open, an outline shows the zone around you. A site's Options panel includes
|
||||
// it as one of its tabs.
|
||||
//
|
||||
// ns.vrSafeZoneOptions.content() -> the Options style's 'rows' content (and asks for the outline)
|
||||
// ns.vrSafeZoneOptions.event(ev) -> true if it was one of this tab's controls
|
||||
// ('slider:<width|height|shift>', 'default:<width|height|shift>')
|
||||
// ('slider:<width|height|shift>', 'default:<width|height|shift>', 'switch:sz-recenter')
|
||||
// ns.vrSafeZoneOptions.recenterResets() -> bool; ns.vrSafeZoneOptions.reset() (the switch off)
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
const SZ = ns.vrSafeZone;
|
||||
const IDS = ['width', 'height', 'shift'];
|
||||
const tr = (key, vars) => ns.i18n.t(key, vars); // words in the interface language (i18n.js)
|
||||
const RECENTER_KEY = 'recenterResets'; // storage.js name: 'on' while the switch is on
|
||||
const recenterResets = () => ns.storage.get(RECENTER_KEY) === 'on';
|
||||
|
||||
// A slider's position (0..1) for a value in its range, and back.
|
||||
const fractionOf = (id, x) => { const [a, b] = SZ.RANGES[id]; return (x - a) / (b - a); };
|
||||
@@ -35,12 +40,17 @@
|
||||
});
|
||||
return {
|
||||
kind: 'rows', note: tr('safezone.note', { h: SZ.fov.h, v: SZ.fov.v }),
|
||||
rows: [row('width', ['narrower', 'wider']), row('height', ['shorter', 'taller']), row('shift', ['lower', 'higher'])],
|
||||
rows: [row('width', ['narrower', 'wider']), row('height', ['shorter', 'taller']), row('shift', ['lower', 'higher']),
|
||||
{ label: tr('safezone.recenter'), hint: tr('safezone.recenterHint'), control: { type: 'switch', id: 'sz-recenter', on: recenterResets() } }],
|
||||
};
|
||||
}
|
||||
|
||||
// The sliders snap to whole percents (so the remembered values stay tidy).
|
||||
function event(ev) {
|
||||
if (ev.type === 'switch' && ev.value === 'sz-recenter') {
|
||||
if (recenterResets()) ns.storage.remove(RECENTER_KEY); else ns.storage.set(RECENTER_KEY, 'on');
|
||||
return true;
|
||||
}
|
||||
const id = typeof ev.value === 'string' && ev.value.startsWith('sz-') ? ev.value.slice(3) : null;
|
||||
if (!IDS.includes(id)) return false;
|
||||
if (ev.type === 'slider') SZ.set({ [id]: Math.round(valueAt(id, ev.fraction) * 100) / 100 });
|
||||
@@ -49,5 +59,5 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
ns.vrSafeZoneOptions = { content, event };
|
||||
ns.vrSafeZoneOptions = { content, event, recenterResets, reset: () => ns.storage.remove(RECENTER_KEY) };
|
||||
})(globalThis.__streamxr = globalThis.__streamxr || {});
|
||||
@@ -0,0 +1,226 @@
|
||||
// A chat (canvas 2D), drawn by the Info style's 'chat' content (info.js): rows oldest first,
|
||||
// each a round picture, the author's name (in a colour, or on a coloured chip, with small
|
||||
// badges before it) and the message flowing after it on the same line, words and pictures
|
||||
// (emojis) wrapping together; highlighted messages as coloured cards (an amount, a header
|
||||
// line, a sticker); notices in grey after an info icon; and a pinned message in a banner
|
||||
// above the list. Knows nothing about any site: colours come as token specs (tokens.js
|
||||
// resolve), pictures as URLs.
|
||||
//
|
||||
// const chat = ns.vrStyleChat.create(g, kit); kit: a card kit (kit.js) for the pictures
|
||||
// chat.layout(items, width) -> { rows: [{ item, y, h, lay }], height } rows laid out
|
||||
// for a list `width` px wide (each item's layout is kept, by id and width)
|
||||
// chat.draw(lay, x0, top, view) the rows, the list's top at `top`, left edge x0; only
|
||||
// what's inside `view` ({ y, h })
|
||||
// chat.bannerHeight(banner, width), chat.drawBanner(banner, x, y, width) the pinned
|
||||
// message's box (0 tall for none)
|
||||
// chat.drawInline(runs, x, midY, maxW, color) -> px drawn runs on one line (a message
|
||||
// being typed), its end kept in view when it's longer than maxW
|
||||
// chat.drawRank(rank, x, midY) -> px wide a rank pill ({ title: "#1", bg, fg } specs)
|
||||
// item: { id, kind ('text' | 'card' | 'notice'), author, avatar, nameColor (spec), nameFill
|
||||
// (spec: a chip behind the name), rank (a pill first: { title, bg, fg }, e.g. a top fan's
|
||||
// "#1"), badges: [{ icon (symbols.js), color (spec) } | { image }], runs:
|
||||
// [{ text } | { image, label }], card (kind 'card'): { color, onColor (specs), title
|
||||
// (e.g. an amount), header (a line under it), sticker (a picture URL) } }
|
||||
// banner: { header, item }
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
const T = ns.vrTokens, P = T.m3, S = ns.vrSymbols; // design tokens (tokens.js), icons
|
||||
const { roundRect, FONT } = ns.vrKit;
|
||||
const R = { avatar: 36, gap: 14, size: 21, line: 29, pad: 7, emoji: 26, badge: 22, nameGap: 10 };
|
||||
const CARD = { pad: 14, head: 58, sticker: 76, radius: 14, margin: 6 };
|
||||
const BANNER = { pad: 14, avatar: 28, lines: 3, header: 26 };
|
||||
const spec = s => (s ? T.resolve(s) : null);
|
||||
|
||||
function create(g, kit) {
|
||||
const cache = new Map(); // `${width}:${id}` -> an item's layout
|
||||
|
||||
// Lay the name, badges and message of `it` out in lines `maxW` wide: [[{ x, w, text |
|
||||
// image | badge | name }]]. The name and badges start the first line; words and
|
||||
// pictures follow, a word too long for a line broken between letters.
|
||||
function flow(it, maxW, withName) {
|
||||
const lines = [[]];
|
||||
let x = 0;
|
||||
const put = (piece, w) => {
|
||||
if (x > 0 && x + w > maxW) { lines.push([]); x = 0; }
|
||||
lines[lines.length - 1].push({ ...piece, x, w }); x += w;
|
||||
};
|
||||
if (withName) {
|
||||
if (it.rank && it.rank.title) put({ rank: it.rank }, rankWidth(it.rank) + 6);
|
||||
for (const b of it.badges || []) put({ badge: b }, R.badge + 6);
|
||||
if (it.author) {
|
||||
g.font = `500 ${R.size}px ${FONT}`;
|
||||
const pad = it.nameFill ? 8 : 0;
|
||||
put({ name: it.author }, g.measureText(it.author).width + pad * 2 + R.nameGap);
|
||||
}
|
||||
}
|
||||
g.font = `${R.size}px ${FONT}`;
|
||||
for (const r of it.runs || []) {
|
||||
if (r.image) { put({ image: r.image, label: r.label }, R.emoji + 4); continue; }
|
||||
for (const word of String(r.text).match(/\S+\s*|\s+/g) || []) {
|
||||
if (!word.trim() && x === 0) continue; // no spaces at a line's start
|
||||
const w = g.measureText(word).width;
|
||||
if (w <= maxW) { put({ text: word }, w); continue; }
|
||||
for (const ch of word) put({ text: ch }, g.measureText(ch).width);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// One item's layout at `width`: { h, lines, textX, kind }.
|
||||
function layoutItem(it, width) {
|
||||
const key = `${width}:${it.id}`;
|
||||
const hit = cache.get(key);
|
||||
if (hit) return hit;
|
||||
let lay;
|
||||
if (it.kind === 'card') {
|
||||
const c = it.card || {}, inner = width - CARD.pad * 2;
|
||||
const lines = (it.runs || []).length ? flow(it, inner, false) : [];
|
||||
const head = c.sticker ? CARD.sticker : CARD.head;
|
||||
lay = { kind: 'card', lines, head, h: head + (lines.length ? lines.length * R.line + CARD.pad : 0) + CARD.margin * 2 };
|
||||
} else {
|
||||
const textX = R.avatar + R.gap, lines = flow(it, width - textX - 8, it.kind !== 'notice');
|
||||
lay = { kind: it.kind, lines, textX, h: Math.max(R.avatar, lines.length * R.line) + R.pad * 2 };
|
||||
}
|
||||
if (cache.size > 800) cache.clear(); // (a long chat: start the cache over)
|
||||
cache.set(key, lay);
|
||||
return lay;
|
||||
}
|
||||
|
||||
function layout(items, width) {
|
||||
let y = 0;
|
||||
const rows = (items || []).map(item => { const lay = layoutItem(item, width); const r = { item, y, h: lay.h, lay }; y += lay.h; return r; });
|
||||
return { rows, height: y };
|
||||
}
|
||||
|
||||
// The pieces of one laid-out line at (x, y) (the line's top), in `color`; the name in
|
||||
// the item's colour (or on its chip).
|
||||
function drawLine(it, line, x, y, color) {
|
||||
g.textAlign = 'left'; g.textBaseline = 'middle';
|
||||
const mid = y + R.line / 2;
|
||||
for (const p of line) {
|
||||
if (p.rank) { drawRank(p.rank, x + p.x, mid); continue; }
|
||||
if (p.badge) {
|
||||
const b = p.badge;
|
||||
if (b.image) { const img = kit.image(b.image); if (img) kit.drawImageContain(img, x + p.x, mid - R.badge / 2, R.badge, R.badge); }
|
||||
else if (b.icon) { g.fillStyle = spec(b.color) || P.onSurfaceVariant; S.draw(g, b.icon, x + p.x + R.badge / 2, mid, R.badge, true); }
|
||||
} else if (p.name) {
|
||||
g.font = `500 ${R.size}px ${FONT}`;
|
||||
if (it.nameFill) {
|
||||
g.fillStyle = spec(it.nameFill); roundRect(g, x + p.x, mid - 14, p.w - R.nameGap, 28, 14); g.fill();
|
||||
g.fillStyle = spec(it.nameColor) || P.onSurface; g.fillText(p.name, x + p.x + 8, mid + 1);
|
||||
} else { g.fillStyle = spec(it.nameColor) || P.onSurfaceVariant; g.fillText(p.name, x + p.x, mid + 1); }
|
||||
} else if (p.image) {
|
||||
const img = kit.image(p.image);
|
||||
if (img) kit.drawImageContain(img, x + p.x, mid - R.emoji / 2, R.emoji, R.emoji);
|
||||
else { g.fillStyle = T.m3Alpha('onSurface', 0.12); roundRect(g, x + p.x, mid - R.emoji / 2, R.emoji, R.emoji, 6); g.fill(); }
|
||||
} else {
|
||||
g.font = `${R.size}px ${FONT}`; g.fillStyle = color; g.fillText(p.text, x + p.x, mid + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A highlighted message: a rounded card in its colour; the header (picture, name, and the
|
||||
// title or header line under it; a sticker on the right), then the message.
|
||||
function drawCard(it, lay, x, y, w) {
|
||||
const c = it.card || {}, top = y + CARD.margin, h = lay.h - CARD.margin * 2;
|
||||
const on = spec(c.onColor) || P.onSurface;
|
||||
g.fillStyle = spec(c.color) || P.surfaceContainerHighest; roundRect(g, x, top, w, h, CARD.radius); g.fill();
|
||||
if (lay.lines.length) { // the message part a shade darker
|
||||
g.save(); roundRect(g, x, top, w, h, CARD.radius); g.clip();
|
||||
g.fillStyle = T.m3Alpha('shadow', 0.15); g.fillRect(x, top + lay.head, w, h - lay.head);
|
||||
g.restore();
|
||||
}
|
||||
const ay = top + (lay.head - R.avatar) / 2;
|
||||
kit.avatar(it.avatar, it.author, x + CARD.pad, ay, R.avatar);
|
||||
const tx = x + CARD.pad + R.avatar + R.gap, tw = w - (tx - x) - CARD.pad - (c.sticker ? CARD.sticker : 0);
|
||||
g.textAlign = 'left'; g.textBaseline = 'middle'; g.fillStyle = on;
|
||||
g.font = `500 19px ${FONT}`; g.fillText(clip(it.author || '', tw), tx, top + lay.head / 2 - 12);
|
||||
g.font = `700 20px ${FONT}`; g.fillText(clip([c.title, c.header].filter(Boolean).join(' · '), tw), tx, top + lay.head / 2 + 13);
|
||||
if (c.sticker) {
|
||||
const img = kit.image(c.sticker), s = CARD.sticker - 12;
|
||||
if (img) kit.drawImageContain(img, x + w - CARD.pad - s, top + 6, s, s);
|
||||
}
|
||||
lay.lines.forEach((line, i) => drawLine(it, line, x + CARD.pad, top + lay.head + CARD.pad / 2 + i * R.line, on));
|
||||
}
|
||||
// Text cut with "…" to fit maxW (in the current font).
|
||||
function clip(t, maxW) {
|
||||
if (g.measureText(t).width <= maxW) return t;
|
||||
while (t.length > 1 && g.measureText(t + '…').width > maxW) t = t.slice(0, -1);
|
||||
return t + '…';
|
||||
}
|
||||
|
||||
function draw(lay, x0, top, view) {
|
||||
const width = view.w;
|
||||
for (const r of lay.rows) {
|
||||
const y = top + r.y;
|
||||
if (y + r.h < view.y || y > view.y + view.h) continue; // off screen
|
||||
const it = r.item;
|
||||
if (r.lay.kind === 'card') { drawCard(it, r.lay, x0, y, width); continue; }
|
||||
if (r.lay.kind === 'notice') { g.fillStyle = P.onSurfaceVariant; S.draw(g, 'info', x0 + R.avatar / 2, y + R.pad + R.line / 2, 26, false); }
|
||||
else kit.avatar(it.avatar, it.author, x0, y + R.pad + (R.line - R.avatar) / 2, R.avatar);
|
||||
const color = r.lay.kind === 'notice' ? P.onSurfaceVariant : P.onSurface;
|
||||
r.lay.lines.forEach((line, i) => drawLine(it, line, x0 + r.lay.textX, y + R.pad + i * R.line, color));
|
||||
}
|
||||
}
|
||||
|
||||
// The pinned message: a header line, then the message (its first BANNER.lines lines) after
|
||||
// a small picture, on a raised tile.
|
||||
// (laid out as a row whose picture is BANNER.avatar wide, inside the tile's padding)
|
||||
function bannerLayout(b, width) {
|
||||
if (!b || !b.item) return null;
|
||||
const lay = layoutItem({ ...b.item, id: `banner:${b.item.id}` }, width - BANNER.pad * 2 - (BANNER.avatar - R.avatar));
|
||||
const lines = lay.lines.slice(0, BANNER.lines);
|
||||
return { lay, lines, h: BANNER.pad * 2 + (b.header ? BANNER.header : 0) + Math.max(BANNER.avatar, lines.length * R.line) };
|
||||
}
|
||||
const bannerHeight = (b, width) => { const bl = bannerLayout(b, width); return bl ? bl.h : 0; };
|
||||
function drawBanner(b, x, y, width) {
|
||||
const bl = bannerLayout(b, width);
|
||||
if (!bl) return 0;
|
||||
g.fillStyle = P.surfaceContainerHigh; roundRect(g, x, y, width, bl.h, T.shape.medium); g.fill();
|
||||
let ty = y + BANNER.pad;
|
||||
if (b.header) {
|
||||
g.fillStyle = P.onSurfaceVariant; g.font = `18px ${FONT}`; g.textAlign = 'left'; g.textBaseline = 'middle';
|
||||
g.fillText(clip(b.header, width - BANNER.pad * 2), x + BANNER.pad, ty + BANNER.header / 2 - 2);
|
||||
ty += BANNER.header;
|
||||
}
|
||||
kit.avatar(b.item.avatar, b.item.author, x + BANNER.pad, ty + (R.line - BANNER.avatar) / 2, BANNER.avatar);
|
||||
const tx = x + BANNER.pad + BANNER.avatar + R.gap;
|
||||
bl.lines.forEach((line, i) => drawLine(b.item, line, tx, ty + i * R.line, P.onSurface));
|
||||
return bl.h;
|
||||
}
|
||||
|
||||
// A rank pill: its title, bold, in its colours (the tertiary colour pair when it has none).
|
||||
const RANK_FONT = `700 16px ${FONT}`, RANK_H = 24;
|
||||
function rankWidth(rank) { g.font = RANK_FONT; return Math.ceil(g.measureText(rank.title).width) + 16; }
|
||||
function drawRank(rank, x, midY) {
|
||||
const w = rankWidth(rank);
|
||||
g.fillStyle = spec(rank.bg) || P.tertiaryContainer; roundRect(g, x, midY - RANK_H / 2, w, RANK_H, RANK_H / 2); g.fill();
|
||||
g.fillStyle = spec(rank.fg) || P.onTertiaryContainer; g.font = RANK_FONT; g.textAlign = 'center'; g.textBaseline = 'middle';
|
||||
g.fillText(rank.title, x + w / 2, midY + 1);
|
||||
g.textAlign = 'left';
|
||||
return w;
|
||||
}
|
||||
|
||||
function drawInline(runs, x, midY, maxW, color) {
|
||||
g.font = `${R.size}px ${FONT}`; g.textAlign = 'left'; g.textBaseline = 'middle';
|
||||
const pieces = (runs || []).map(r => (r.image ? { image: r.image, w: R.emoji + 4 } : { text: r.text, w: g.measureText(r.text).width }));
|
||||
const total = pieces.reduce((s, p) => s + p.w, 0);
|
||||
g.save(); g.beginPath(); g.rect(x, midY - R.line, maxW, R.line * 2); g.clip();
|
||||
let px = x - Math.max(0, total - maxW); // (longer than the room: its end shows)
|
||||
for (const p of pieces) {
|
||||
if (p.image) {
|
||||
const img = kit.image(p.image);
|
||||
if (img) kit.drawImageContain(img, px, midY - R.emoji / 2, R.emoji, R.emoji);
|
||||
} else { g.fillStyle = color; g.fillText(p.text, px, midY + 1); }
|
||||
px += p.w;
|
||||
}
|
||||
g.restore();
|
||||
return Math.min(total, maxW);
|
||||
}
|
||||
|
||||
return { layout, draw, bannerHeight, drawBanner, drawInline, drawRank };
|
||||
}
|
||||
|
||||
ns.vrStyleChat = { create };
|
||||
})(globalThis.__streamxr = globalThis.__streamxr || {});
|
||||
@@ -10,6 +10,11 @@
|
||||
// author (optionally highlighted, with a badge) · meta, text with clickable clock
|
||||
// times, a footer (a site slot), and children (replies) opened under them;
|
||||
// more load as you scroll (YouTube's Comments).
|
||||
// 'chat': a live chat (chat.js): a pinned message in a banner, then the messages oldest
|
||||
// first, following the newest (scrolled to the bottom) while `follow`; when not,
|
||||
// a "jump to newest" button over the bottom of the list; optionally a row of
|
||||
// reaction buttons (an emoji each) along the panel's bottom, with a Support
|
||||
// button at its right end, and a message field with its Send button above it.
|
||||
// 'message': a grey message in the middle (optionally under the loading spinner).
|
||||
// Knows nothing about any site.
|
||||
//
|
||||
@@ -29,11 +34,20 @@
|
||||
// threads: [{ comment: { id, author, avatar, highlight, badge, meta, text, childrenLabel },
|
||||
// children: null | { open, items: [comments], loading, more } }],
|
||||
// loading, done, error }
|
||||
// { kind: 'chat', banner: null | { header, item }, items: [item] (chat.js; keep each
|
||||
// item object while it's shown: its layout is kept by id), follow, labels: { newest, empty,
|
||||
// send, support }, reactions: [{ id, emoji (a character), on (just sent: shown selected) }],
|
||||
// support (show the Support button), input: null | { runs (what's typed: [{ text } |
|
||||
// { image, label }]), placeholder, focused, canSend, note (a line instead of the field:
|
||||
// why you can't chat), error (shown in the field's corner) }, xp: null | { label }
|
||||
// (a 👑 pill at the right of the tab row), fans: null | { title, loading, error, empty,
|
||||
// rows: [fan], you: fan | null } (a ranked list instead of the messages, with a back
|
||||
// button; fan: { rank, name, avatar, points, badge ({ title, bg, fg } | null), note }) }
|
||||
// { kind: 'message', text, spinner } }
|
||||
// Control ids: 'tab:<id>', 'page:prev', 'page:next', 'row:<index>' (paged / rows),
|
||||
// 'link:<index in links>', 'pill:<id>', 'time:<seconds>', 'children:<thread>',
|
||||
// 'more:<thread>', 'text' (anywhere else in a scrolling area: a trigger drag can start
|
||||
// there), plus the footer slot's own ids.
|
||||
// 'more:<thread>', 'newest', 'react:<id>', 'field', 'send', 'support', 'fans', 'fansBack' (chat), 'text' (anywhere else in a scrolling area: a trigger
|
||||
// drag can start there), plus the footer slot's own ids.
|
||||
// Scrolling: scrollAt(u, v), maxScroll(), shownScroll() (after clamping / following),
|
||||
// pxPerV. Also hitTest(u, v) -> { id } | null, has(id), imagesReady(), perPage.
|
||||
(function (ns) {
|
||||
@@ -279,6 +293,159 @@
|
||||
endScroll();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 'chat'
|
||||
// The banner (if any) under the tabs, then the messages scrolling below it; following the
|
||||
// newest, the list stays scrolled to its end. Its pictures (avatars, emojis: many) have
|
||||
// their own kit, with room for more of them.
|
||||
const chatKit = ns.vrKit.createCardKit(g, onImageLoaded, 400);
|
||||
const chat = ns.vrStyleChat.create(g, chatKit);
|
||||
const NEWEST = { h: 52, pad: 28 }; // the "jump to newest" button
|
||||
const REACT = { size: 64, gap: 16, bar: 92 }; // the reaction buttons; the row's height
|
||||
const INPUT = { h: 64, gap: 12, sendW: 132, btnH: 56 }; // the message field's row
|
||||
|
||||
// The bottom row: the reaction buttons (round tonal buttons, filled while `on`, the emoji
|
||||
// drawn by the font) centred in it, and the Support button at its right end.
|
||||
function drawBottomRow(list, labels, support, ui) {
|
||||
const y = H - REACT.bar + (REACT.bar - REACT.size) / 2 - 6;
|
||||
let right = W - PAD;
|
||||
if (support) {
|
||||
g.font = T.font('labelLarge', 22);
|
||||
const w = g.measureText(labels.support || '').width + 28 + 8 + 48;
|
||||
ns.vrKit.button(g, regions, 'support', right - w, y + (REACT.size - INPUT.btnH) / 2, w, INPUT.btnH, { icon: 'card_membership', label: labels.support, kind: 'tonal' }, ui);
|
||||
right -= w + REACT.gap;
|
||||
}
|
||||
const total = list.length * REACT.size + Math.max(0, list.length - 1) * REACT.gap;
|
||||
let x = Math.max(PAD, (PAD + right) / 2 - total / 2);
|
||||
for (const r of list) {
|
||||
ns.vrKit.button(g, regions, `react:${r.id}`, x, y, REACT.size, REACT.size, { kind: r.on ? 'filled' : 'tonal' }, ui);
|
||||
g.font = `34px ${FONT}`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillStyle = P.onSurface;
|
||||
g.fillText(r.emoji, x + REACT.size / 2, y + REACT.size / 2 + 2);
|
||||
x += REACT.size + REACT.gap;
|
||||
}
|
||||
}
|
||||
|
||||
// The message row at y: a field (what's typed, with emoji pictures; the placeholder when
|
||||
// empty; outlined in the primary colour with a caret while typing) and the Send button; or,
|
||||
// when you can't chat, the note saying why.
|
||||
function drawInput(inp, labels, y, ui) {
|
||||
if (inp.note) {
|
||||
g.fillStyle = P.onSurfaceVariant; g.font = `20px ${FONT}`; g.textAlign = 'left'; g.textBaseline = 'middle';
|
||||
ns.vrKit.wrap(g, inp.note, W - PAD * 2, 2).forEach((line, i, all) => g.fillText(line, PAD, y + INPUT.h / 2 + (i - (all.length - 1) / 2) * 26));
|
||||
return;
|
||||
}
|
||||
const fw = W - PAD * 2 - INPUT.sendW - INPUT.gap, r = INPUT.h / 2;
|
||||
regions.push({ id: 'field', x: PAD, y, w: fw, h: INPUT.h });
|
||||
g.fillStyle = P.surfaceContainerHighest; roundRect(g, PAD, y, fw, INPUT.h, r); g.fill();
|
||||
ns.vrKit.hoverArea(g, 'field', ui, PAD, y, fw, INPUT.h, r);
|
||||
if (inp.focused) { g.strokeStyle = P.primary; g.lineWidth = 3; roundRect(g, PAD + 1.5, y + 1.5, fw - 3, INPUT.h - 3, r - 1.5); g.stroke(); }
|
||||
const tx = PAD + 24, room = fw - 48 - (inp.error ? 140 : 0), mid = y + INPUT.h / 2;
|
||||
let end = tx;
|
||||
if ((inp.runs || []).length) end = tx + chat.drawInline(inp.runs, tx, mid, room, P.onSurface);
|
||||
else if (!inp.focused) {
|
||||
g.fillStyle = P.onSurfaceVariant; g.font = `21px ${FONT}`; g.textAlign = 'left'; g.textBaseline = 'middle';
|
||||
g.fillText(inp.placeholder || '', tx, mid + 1);
|
||||
}
|
||||
if (inp.focused) { g.fillStyle = P.primary; g.fillRect(end + 2, mid - 14, 3, 28); }
|
||||
if (inp.error) {
|
||||
g.fillStyle = P.error; g.font = `18px ${FONT}`; g.textAlign = 'right'; g.textBaseline = 'middle';
|
||||
g.fillText(inp.error, PAD + fw - 24, mid + 1);
|
||||
}
|
||||
ns.vrKit.button(g, regions, 'send', W - PAD - INPUT.sendW, y + (INPUT.h - INPUT.btnH) / 2, INPUT.sendW, INPUT.btnH,
|
||||
{ label: labels.send, kind: 'filled', enabled: !!inp.canSend }, ui);
|
||||
}
|
||||
|
||||
// The XP pill at the right end of the tab row (its width: xpWidth, also used to keep the
|
||||
// tabs clear of it).
|
||||
const XP = { h: 48, y: 18 };
|
||||
const xpLabel = xp => `👑 ${xp.label}`;
|
||||
function xpWidth(c) {
|
||||
if (!c || c.kind !== 'chat' || !c.xp) return 0;
|
||||
g.font = T.font('labelLarge', 22); return Math.ceil(g.measureText(xpLabel(c.xp)).width) + 40;
|
||||
}
|
||||
|
||||
// Top fans: a back button and the title, the ranked rows scrolling (rank or its badge, picture,
|
||||
// name, XP), and your own row pinned under them with YouTube's line about it.
|
||||
const FAN = { h: 64, avatar: 40, rankW: 70, head: 64 };
|
||||
function drawFanRow(f, x, y, w, you) {
|
||||
const mid = y + FAN.h / 2;
|
||||
if (f.badge) chat.drawRank(f.badge, x + 4, mid);
|
||||
else { g.fillStyle = P.onSurfaceVariant; g.font = `500 20px ${FONT}`; g.textAlign = 'left'; g.textBaseline = 'middle'; g.fillText(f.rank || '–', x + 8, mid + 1); }
|
||||
kit.avatar(f.avatar, f.name, x + FAN.rankW, mid - FAN.avatar / 2, FAN.avatar);
|
||||
g.font = `500 21px ${FONT}`; g.textAlign = 'right'; g.textBaseline = 'middle'; g.fillStyle = you ? P.primary : P.onSurface;
|
||||
g.fillText(f.points || '', x + w - 8, mid + 1);
|
||||
const pw = g.measureText(f.points || '').width, nx = x + FAN.rankW + FAN.avatar + 14;
|
||||
g.textAlign = 'left'; g.font = `${you ? 500 : 400} 21px ${FONT}`; g.fillStyle = P.onSurface;
|
||||
const room = x + w - 8 - pw - 16 - nx;
|
||||
let name = f.name || '';
|
||||
while (name.length > 1 && g.measureText(name).width > room) name = name.slice(0, -2) + '…';
|
||||
g.fillText(name, nx, mid + 1);
|
||||
}
|
||||
function drawFans(fans, labels, m, ui) {
|
||||
const top = TOP;
|
||||
ns.vrKit.button(g, regions, 'fansBack', PAD - 6, top + 4, 52, 52, { icon: 'arrow_back' }, ui);
|
||||
g.fillStyle = P.onSurface; g.font = `500 24px ${FONT}`; g.textAlign = 'left'; g.textBaseline = 'middle';
|
||||
g.fillText(fans.title || '', PAD + 60, top + 30);
|
||||
// Your row at the bottom: a divider, the row, then YouTube's line (up to two lines).
|
||||
let bottom = H - 20;
|
||||
if (fans.you) {
|
||||
g.font = `18px ${FONT}`;
|
||||
const note = fans.you.note ? ns.vrKit.wrap(g, fans.you.note, W - PAD * 2, 2) : [];
|
||||
const youH = FAN.h + note.length * 24 + (note.length ? 8 : 0);
|
||||
bottom = H - 20 - youH;
|
||||
g.fillStyle = P.outlineVariant; g.fillRect(PAD, bottom - 6, W - PAD * 2, 2);
|
||||
drawFanRow(fans.you, PAD, bottom, W - PAD * 2, true);
|
||||
g.fillStyle = P.onSurfaceVariant; g.font = `18px ${FONT}`; g.textAlign = 'left'; g.textBaseline = 'top';
|
||||
note.forEach((line, i) => g.fillText(line, PAD, bottom + FAN.h + i * 24));
|
||||
bottom -= 14;
|
||||
}
|
||||
const view = { x: VIEW.x, y: top + FAN.head, w: VIEW.w, h: bottom - top - FAN.head };
|
||||
const rows = fans.rows || [];
|
||||
if (!rows.length) {
|
||||
const cy = view.y + view.h / 2;
|
||||
if (fans.loading) ns.vrGlyphs.spinner(g, W / 2, cy - 20, 26, m.now || 0, false);
|
||||
g.fillStyle = P.onSurfaceVariant; g.font = `22px ${FONT}`; g.textAlign = 'center'; g.textBaseline = 'middle';
|
||||
g.fillText(fans.loading ? '' : fans.error || fans.empty || '', W / 2, cy + 20);
|
||||
return;
|
||||
}
|
||||
beginScroll(m, rows.length * FAN.h + 8, null, view);
|
||||
g.save(); g.beginPath(); g.rect(view.x - 8, view.y, view.w + 16, view.h); g.clip();
|
||||
rows.forEach((f, i) => {
|
||||
const y = view.y - scrollPx + i * FAN.h;
|
||||
if (y + FAN.h >= view.y && y <= view.y + view.h) drawFanRow(f, view.x, y, view.w, false);
|
||||
});
|
||||
g.restore();
|
||||
endScroll();
|
||||
}
|
||||
|
||||
function drawChat(c, m, ui) {
|
||||
const reactions = c.reactions || [], labels = c.labels || {};
|
||||
if (c.xp) { // your XP, opening Top fans
|
||||
const w = xpWidth(c);
|
||||
ns.vrKit.button(g, regions, 'fans', W - PAD - w, XP.y, w, XP.h, { label: xpLabel(c.xp), kind: c.fans ? 'filled' : 'tonal' }, ui);
|
||||
}
|
||||
if (c.fans) { drawFans(c.fans, labels, m, ui); return; }
|
||||
let bottom = H - 20; // where the list ends: above the bottom rows
|
||||
if (reactions.length || c.support) { drawBottomRow(reactions, labels, !!c.support, ui); bottom = H - REACT.bar; }
|
||||
if (c.input) { drawInput(c.input, labels, bottom - INPUT.h - 6, ui); bottom -= INPUT.h + 18; }
|
||||
const bannerH = chat.bannerHeight(c.banner, VIEW.w);
|
||||
if (bannerH) chat.drawBanner(c.banner, VIEW.x, TOP, VIEW.w);
|
||||
const top = TOP + (bannerH ? bannerH + 10 : 0);
|
||||
const view = { x: VIEW.x, y: top, w: VIEW.w, h: bottom - top };
|
||||
if (!(c.items || []).length) { message((c.labels || {}).empty || ''); return; }
|
||||
const lay = chat.layout(c.items, view.w);
|
||||
beginScroll(m, lay.height + 8, c.follow ? lay.height : null, view);
|
||||
g.save(); g.beginPath(); g.rect(view.x - 8, view.y, view.w + 16, view.h); g.clip();
|
||||
chat.draw(lay, view.x, view.y - scrollPx, view);
|
||||
g.restore();
|
||||
if (!c.follow) { // scrolled back: a way to the newest
|
||||
const label = (c.labels || {}).newest || '';
|
||||
g.font = T.font('labelLarge', 22);
|
||||
const w = g.measureText(label).width + 28 + 8 + NEWEST.pad * 2;
|
||||
ns.vrKit.button(g, regions, 'newest', W / 2 - w / 2, view.y + view.h - NEWEST.h - 16, w, NEWEST.h, { icon: 'expand_more', label, kind: 'filled' }, ui);
|
||||
}
|
||||
endScroll();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ scrolling
|
||||
// Start drawing a scrolling area (`view`, VIEW unless given) with `contentH` of content:
|
||||
// clamp the scroll (or use `want`, e.g. the rows following their current one) and
|
||||
@@ -313,12 +480,15 @@
|
||||
// The tabs end before the page count and arrows when the content is paged.
|
||||
const pc = m.content && m.content.kind === 'paged' && m.content.pageCount > 1 ? m.content : null;
|
||||
g.font = `22px ${FONT}`;
|
||||
headerTabs(g, m.tabs, m.tab, ui, regions, PAD, { maxX: pc ? ARROWS.prev.x - 32 - g.measureText(`${pc.page + 1} / ${pc.pageCount}`).width : W - PAD });
|
||||
const xpW = xpWidth(m.content); // (a chat's XP pill at the right of the tabs)
|
||||
g.font = `22px ${FONT}`;
|
||||
headerTabs(g, m.tabs, m.tab, ui, regions, PAD, { maxX: pc ? ARROWS.prev.x - 32 - g.measureText(`${pc.page + 1} / ${pc.pageCount}`).width : W - PAD - (xpW ? xpW + 16 : 0) });
|
||||
const c = m.content || { kind: 'message', text: '' };
|
||||
if (c.kind === 'paged') drawPaged(c, ui);
|
||||
else if (c.kind === 'text') drawText(c, m, ui);
|
||||
else if (c.kind === 'rows') drawRows(c, m, ui);
|
||||
else if (c.kind === 'thread') drawThread(c, m, ui);
|
||||
else if (c.kind === 'chat') drawChat(c, m, ui);
|
||||
else message(c.text || '', c.spinner ? m.now || 0 : null);
|
||||
}
|
||||
|
||||
@@ -328,7 +498,8 @@
|
||||
const scrollAt = (u, v) => scrollable && u * W >= curView.x - 8 && u * W <= W - 16 && v * H >= curView.y && v * H <= curView.y + curView.h;
|
||||
|
||||
return {
|
||||
canvas, aspect: H / W, draw, hitTest, has, scrollAt, imagesReady: kit.imagesReady, perPage: PER_PAGE, animating: motion.moving,
|
||||
canvas, aspect: H / W, draw, hitTest, has, scrollAt, perPage: PER_PAGE, animating: motion.moving,
|
||||
imagesReady: () => kit.imagesReady() + chatKit.imagesReady(),
|
||||
maxScroll: () => maxScrollPx, // how far the last drawn tab can scroll (px)
|
||||
shownScroll: () => scrollPx, // where it actually is (after clamping / following)
|
||||
pxPerV: H, // canvas pixels per unit of v (for drag scrolling)
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
// current (outlined in blue: the item playing now),
|
||||
// progress (0-1: how much of it you've watched, a bar along the thumbnail's bottom in
|
||||
// the brand colour (the site's theme); 0 or missing: no bar),
|
||||
// fit ('contain': the whole picture, letterboxed, e.g. a product; default: cropped to fill),
|
||||
// fit ('contain': the whole picture, letterboxed, e.g. a product; 'round': a round picture
|
||||
// in the middle, e.g. a person or a channel (a letter until it loads); default:
|
||||
// cropped to fill),
|
||||
// inert (just to look at: not clickable, no hover highlight) }
|
||||
// Badges: a site registers its own (registerBadge); drawBadge(name) draws one if registered.
|
||||
(function (ns) {
|
||||
@@ -90,13 +92,15 @@
|
||||
const cardHeight = (thumbH, lines = 2) => thumbH + 60 + 25 * lines;
|
||||
|
||||
// Images and cards drawing on the 2D context `g`. onImageLoaded(): called when an image
|
||||
// finishes loading, so the caller redraws. Returns { image(url), imagesReady(),
|
||||
// finishes loading, so the caller redraws; limit: how many images to keep (default 160).
|
||||
// Returns { image(url), imagesReady(),
|
||||
// drawImageCover(img, x, y, w, h), drawImageContain(img, x, y, w, h), avatar(url, name, x, y, s),
|
||||
// drawCard(card, x, y, box, ui, regions, id) }.
|
||||
function createCardKit(g, onImageLoaded) {
|
||||
function createCardKit(g, onImageLoaded, limit = 160) {
|
||||
// Images (thumbnails, avatars, banners): loaded with CORS so the canvas stays usable as
|
||||
// a WebGL texture. Returns the image once loaded, else null (and starts loading it). If
|
||||
// the image server refuses, callers draw a plain placeholder.
|
||||
// the image server refuses, callers draw a plain placeholder. At most `limit` are kept
|
||||
// (the oldest go first): enough for everything one panel shows at once.
|
||||
const images = new Map(); // url -> { img, ok }
|
||||
function image(url) {
|
||||
if (!url) return null;
|
||||
@@ -108,7 +112,7 @@
|
||||
t.img.onerror = () => { t.ok = false; };
|
||||
t.img.src = url;
|
||||
images.set(url, t);
|
||||
if (images.size > 160) images.delete(images.keys().next().value); // keep the cache small
|
||||
if (images.size > limit) images.delete(images.keys().next().value); // keep the cache small
|
||||
}
|
||||
return t.ok ? t.img : null;
|
||||
}
|
||||
@@ -166,7 +170,11 @@
|
||||
// Thumbnail (a grey box with a play triangle until it loads)
|
||||
g.save(); roundRect(g, x, y, W_, TH, T.shape.medium); g.clip();
|
||||
const img = image(card.thumb);
|
||||
if (img && card.fit === 'contain') { g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH); drawImageContain(img, x, y, W_, TH); }
|
||||
if (card.fit === 'round') { // a round picture (a person) in the middle of the box
|
||||
const s = Math.round(TH * 0.8);
|
||||
g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH);
|
||||
avatar(card.thumb, card.title, x + (W_ - s) / 2, y + (TH - s) / 2, s);
|
||||
} else if (img && card.fit === 'contain') { g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH); drawImageContain(img, x, y, W_, TH); }
|
||||
else if (img) drawImageCover(img, x, y, W_, TH);
|
||||
else {
|
||||
g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH);
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
// instead of the title while header is null),
|
||||
// action: { id, label, look: 'primary' | 'secondary' | 'danger' | 'busy' } | null
|
||||
// (one button on the right; 'busy' isn't clickable),
|
||||
// iconAction: { id, icon, look: 'primary' | 'secondary' | 'busy' } | null (a round
|
||||
// icon button just left of the action button, e.g. one that opens a menu),
|
||||
// tabs: [{ id, label }], tab, pills: [{ id, label }], pill,
|
||||
// search: bool (a search button at the tab row's end, 'search'),
|
||||
// field: null | a text field (grid.js: { id, text, placeholder, focused, button,
|
||||
@@ -41,7 +43,9 @@
|
||||
// 'clear:<id>' / 'submit:<id>' (searching), the thread's own (thread.js), 'text' (anywhere
|
||||
// else on a scrolling body: a trigger drag can start there).
|
||||
// Scrolling (a body): scrollAt(u, v), maxScroll(), shownScroll(), pxPerV.
|
||||
// Also: hitTest(u, v) -> { id } | null, has(id), imagesReady(), perPage, canvas, aspect.
|
||||
// Also: hitTest(u, v) -> { id } | null, has(id), rectOf(id) -> { x, y, w, h } (px) | null
|
||||
// (a control's place in the last draw, for a menu opening from it), imagesReady(),
|
||||
// perPage, canvas, aspect.
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
@@ -60,6 +64,7 @@
|
||||
// the same everywhere on this page).
|
||||
function create(onImageLoaded, W = 1280, H = 960, overrides = {}) {
|
||||
const ACTION = { x: W - PAD - 240, y: 132, w: 240, h: 60 };
|
||||
const ICON_ACTION = { x: ACTION.x - 60 - 16, y: ACTION.y, w: 60, h: 60 };
|
||||
// A narrow panel puts the pills on a row of their own under the tabs, so the tabs keep
|
||||
// their names; everything below moves down with it.
|
||||
const PILLS_Y = W < NARROW ? TAB_Y + TAB_H + 10 : TAB_Y;
|
||||
@@ -98,7 +103,7 @@
|
||||
// Name (+ badge) and the stats line beside the picture.
|
||||
function drawTitle(m) {
|
||||
const h = m.header;
|
||||
const maxW = ACTION.x - NAME_X - 24;
|
||||
const maxW = (m.iconAction ? ICON_ACTION.x : ACTION.x) - NAME_X - 24;
|
||||
g.textAlign = 'left'; g.textBaseline = 'middle';
|
||||
g.fillStyle = P.onSurface; g.font = T.font('titleLargeEmphasized', 36);
|
||||
const name = wrap(g, h ? h.title : m.loadingTitle, maxW - (h && h.badge ? 34 : 0), 1)[0] || '';
|
||||
@@ -117,6 +122,9 @@
|
||||
if (!a) return;
|
||||
ns.vrKit.button(g, regions, `action:${a.id}`, ACTION.x, ACTION.y, ACTION.w, ACTION.h,
|
||||
{ label: a.label, kind: KIND[a.look] || 'filled', enabled: a.look !== 'busy' }, ui);
|
||||
const i = m.iconAction; // the round icon button left of it
|
||||
if (i) ns.vrKit.button(g, regions, `action:${i.id}`, ICON_ACTION.x, ICON_ACTION.y, ICON_ACTION.w, ICON_ACTION.h,
|
||||
{ icon: i.icon, kind: KIND[i.look] || 'tonal', enabled: i.look !== 'busy' }, ui);
|
||||
}
|
||||
|
||||
// The featured card (id 'featured'): the thumbnail on the left (with its dot flags drawn
|
||||
@@ -449,11 +457,12 @@
|
||||
// (It also tells the sliding rows where the laser is: grid.js strips.)
|
||||
const hitTest = (u, v) => { grid.point(u * W, v * H); const r = regionAt(regions, u, v, W, H); return r ? { id: r.id } : null; };
|
||||
const has = id => regions.some(r => r.id === id); // an enabled control from the last draw?
|
||||
const rectOf = id => { const r = regions.find(x => x.id === id); return r ? { x: r.x, y: r.y, w: r.w, h: r.h } : null; };
|
||||
// Is (u, v) on the scrolling body of the last draw?
|
||||
const scrollAt = (u, v) => scrollable && u * W >= curView.x - 8 && u * W <= W - 16 && v * H >= curView.y && v * H <= curView.y + curView.h;
|
||||
|
||||
return {
|
||||
canvas, aspect: H / W, draw, hitTest, has, scrollAt, imagesReady: kit.imagesReady, perPage: grid.PER_PAGE, animating: () => motion.moving() || grid.moving(),
|
||||
canvas, aspect: H / W, draw, hitTest, has, rectOf, scrollAt, imagesReady: kit.imagesReady, perPage: grid.PER_PAGE, animating: () => motion.moving() || grid.moving(),
|
||||
maxScroll: () => maxScrollPx, // how far the last drawn body can scroll (px)
|
||||
shownScroll: () => scrollPx, // where it actually is (after clamping)
|
||||
pxPerV: H, // canvas pixels per unit of v (for drag scrolling)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//
|
||||
// ns.vrSymbols.draw(g, name, cx, cy, size, filled) centred on (cx, cy), `size` px square;
|
||||
// returns false for an unknown name
|
||||
// ns.vrSymbols.drawIcon(g, icon, cx, cy, size, filled) icon: a name, or { name, color, filled }
|
||||
// ns.vrSymbols.has(name), ns.vrSymbols.NAMES
|
||||
//
|
||||
// Material Symbols: Copyright Google LLC, Apache License 2.0
|
||||
@@ -44,6 +45,12 @@
|
||||
'3d_rotation': [
|
||||
'M620-360H520q-8 0-14-6t-6-14v-200q0-8 6-14t14-6h100q17 0 28.5 11.5T660-560v160q0 17-11.5 28.5T620-360Zm-200 0h-90q-13 0-21.5-8.5T300-390q0-13 8.5-21.5T330-420h70v-40h-40q-8 0-14-6t-6-14q0-8 6-14t14-6h40v-40h-70q-13 0-21.5-8.5T300-570q0-13 8.5-21.5T330-600h90q17 0 28.5 11.5T460-560v160q0 17-11.5 28.5T420-360Zm140-60h40v-120h-40v120Zm280-20q-17 0-28.5-11.5T800-480q0-115-72.5-203T542-794l30 30q11 11 11 28t-11 28q-11 11-28 11t-28-11L387-837q-8-8-5-20t15-15q20-5 41-6.5t42-1.5q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 17-11.5 28.5T840-440ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-17 11.5-28.5T120-520q17 0 28.5 11.5T160-480q0 115 72.5 203T418-166l-30-30q-11-11-11-28t11-28q11-11 28-11t28 11l129 129q8 8 5 20t-15 15q-20 5-41 6.5T480-80Z',
|
||||
'M620-360H520q-8 0-14-6t-6-14v-200q0-8 6-14t14-6h100q17 0 28.5 11.5T660-560v160q0 17-11.5 28.5T620-360Zm-200 0h-90q-13 0-21.5-8.5T300-390q0-13 8.5-21.5T330-420h70v-40h-40q-8 0-14-6t-6-14q0-8 6-14t14-6h40v-40h-70q-13 0-21.5-8.5T300-570q0-13 8.5-21.5T330-600h90q17 0 28.5 11.5T460-560v160q0 17-11.5 28.5T420-360Zm140-60h40v-120h-40v120Zm280-20q-17 0-28.5-11.5T800-480q0-115-72.5-203T542-794l30 30q11 11 11 28t-11 28q-11 11-28 11t-28-11L387-837q-8-8-4.5-20t14.5-15q20-5 41-6.5t42-1.5q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 17-11.5 28.5T840-440ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-17 11.5-28.5T120-520q17 0 28.5 11.5T160-480q0 115 72.5 203T418-166l-30-30q-11-11-11-28t11-28q11-11 28-11t28 11l129 129q8 8 4.5 20T563-88q-20 5-41 6.5T480-80Z'],
|
||||
'2d_2': [
|
||||
'M376-279H172q-11 0-18-7t-7-18v-32q0-5 1.5-10.5t6.5-8.5q51-41 84.5-68.5T292-478q16-22 24-41.5t8-46.5q0-23-17-39.5T266-622q-17 0-29.5 9.5T216-589q-7 13-20.5 18t-26.5-1q-14-7-17-22t3-29q14-32 45.5-49.5T266-690q63 0 97.5 32t34.5 90q0 37-14 69t-36 61q-23 31-46.5 50.5T249-346l2 3h125q14 0 23 9t9 23q0 14-9 23t-23 9Zm152-1q-17 0-28.5-11.5T488-320v-321q0-17 11.5-28.5T528-681h94q97 0 148.5 52T822-480q0 97-52 148.5T622-280h-94Zm36-72h54q66 0 97.5-31t31.5-97q0-66-31.5-97.5T618-609h-54v257Z',
|
||||
'M376-279H172q-11 0-18-7t-7-18v-32q0-5 1.5-10.5t6.5-8.5q51-41 84.5-68.5T292-478q16-22 24-40.5t8-38.5q0-30-17-47.5T266-622q-17 0-29.5 9.5T216-589q-7 13-20.5 18t-26.5-1q-14-7-17-22t3-29q14-32 45.5-49.5T266-690q63 0 97.5 32t34.5 90q0 37-14 69t-36 61q-23 31-46.5 50.5T249-346l2 3h125q14 0 23 9t9 23q0 14-9 23t-23 9Zm152-1q-17 0-28.5-11.5T488-320v-321q0-17 11.5-28.5T528-681h94q97 0 148.5 52T822-480q0 97-52 148.5T622-280h-94Zm36-72h54q66 0 97.5-31t31.5-97q0-66-31.5-97.5T618-609h-54v257Z'],
|
||||
'3d_2': [
|
||||
'M530-279q-17 0-28.5-11.5T490-319v-321q0-17 11.5-28.5T530-680h93q97 0 148.5 51.5T823-480q0 97-52 149t-148 52h-93Zm35-72h55q66 0 97-31.5t31-97.5q0-66-31-97.5T620-609h-55v258Zm-289 81q-45 0-78-17.5T149-340q-7-14-1-29t20-22q14-7 28.5-1.5T219-373q8 16 22.5 24.5T276-340q28 0 45-15.5t17-42.5q0-26-18-43t-51-17h-2q-14 0-23-9t-9-23q0-14 9-23t23-9q30 0 45-14t15-37q0-23-14.5-36.5T274-623q-18 0-31 8t-21 22q-7 14-21 18t-28-3q-14-7-18-21t3-28q6-12 14.5-21.5T192-666q16-11 37-17t46-6q55 0 89.5 27.5T399-581q0 30-13.5 50.5T350-496v4q30 11 46 39t16 61q0 59-37.5 90.5T276-270Z',
|
||||
'M530-279q-17 0-28.5-11.5T490-319v-321q0-17 11.5-28.5T530-680h93q97 0 148.5 51.5T823-480q0 97-52 149t-148 52h-93Zm35-72h55q66 0 97-31.5t31-97.5q0-66-31-97.5T620-609h-55v258Zm-289 81q-45 0-78-17.5T149-340q-7-14-1-29t20-22q14-7 28.5-1.5T219-373q8 16 22.5 24.5T276-340q28 0 45-15.5t17-42.5q0-26-18-43t-51-17h-2q-14 0-23-9t-9-23q0-14 9-23t23-9q30 0 45-14t15-37q0-23-14.5-36.5T274-623q-18 0-31 8t-21 22q-7 14-21 18t-28-3q-14-7-18-21t3-28q6-12 14.5-21.5T192-666q16-11 37-17t46-6q55 0 89.5 27.5T399-581q0 30-13.5 50.5T350-496v4q30 11 46 39t16 61q0 59-37.5 90.5T276-270Z'],
|
||||
account_box: [
|
||||
'M200-246q54-53 125.5-83.5T480-360q83 0 154.5 30.5T760-246v-514H200v514Zm280-194q58 0 99-41t41-99q0-58-41-99t-99-41q-58 0-99 41t-41 99q0 58 41 99t99 41ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm80-80h400v-10q-42-35-93-52.5T480-280q-56 0-107 17.5T280-210v10Zm200-320q-25 0-42.5-17.5T420-580q0-25 17.5-42.5T480-640q25 0 42.5 17.5T540-580q0 25-17.5 42.5T480-520Zm0 17Z',
|
||||
'M480-440q58 0 99-41t41-99q0-58-41-99t-99-41q-58 0-99 41t-41 99q0 58 41 99t99 41ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-46q-54-53-125.5-83.5T480-360q-83 0-154.5 30.5T200-246v46Z'],
|
||||
@@ -56,12 +63,21 @@
|
||||
arrow_back: [
|
||||
'm313-440 196 196q12 12 11.5 28T508-188q-12 11-28 11.5T452-188L188-452q-6-6-8.5-13t-2.5-15q0-8 2.5-15t8.5-13l264-264q11-11 27.5-11t28.5 11q12 12 12 28.5T508-715L313-520h447q17 0 28.5 11.5T800-480q0 17-11.5 28.5T760-440H313Z',
|
||||
'm313-440 196 196q12 12 11.5 28T508-188q-12 11-28 11.5T452-188L188-452q-6-6-8.5-13t-2.5-15q0-8 2.5-15t8.5-13l264-264q11-11 27.5-11t28.5 11q12 12 12 28.5T508-715L313-520h447q17 0 28.5 11.5T800-480q0 17-11.5 28.5T760-440H313Z'],
|
||||
back_hand: [
|
||||
'M512-40q-82 0-154-37.5T240-182L63-442q-7-10-5.5-21.5T67-483q20-21 49.5-24t53.5 14l110 76v-383q0-17 11.5-28.5T320-840q17 0 28.5 11.5T360-800v460q0 24-21.5 35.5T297-307l-85-59 95 138q35 51 89 79.5T512-120q103 0 175.5-72.5T760-368v-392q0-17 11.5-28.5T800-800q17 0 28.5 11.5T840-760v392q0 137-95.5 232.5T512-40Zm-32-880q17 0 28.5 11.5T520-880v360q0 17-11.5 28.5T480-480q-17 0-28.5-11.5T440-520v-360q0-17 11.5-28.5T480-920Zm160 40q17 0 28.5 11.5T680-840v320q0 17-11.5 28.5T640-480q-17 0-28.5-11.5T600-520v-320q0-17 11.5-28.5T640-880ZM486-300Z',
|
||||
'M512-40q-82 0-154-37.5T240-182L63-442q-7-10-5.5-21.5T67-483q20-21 49.5-24t53.5 14l110 76v-383q0-17 11.5-28.5T320-840q17 0 28.5 11.5T360-800v320h80v-400q0-17 11.5-28.5T480-920q17 0 28.5 11.5T520-880v400h80v-360q0-17 11.5-28.5T640-880q17 0 28.5 11.5T680-840v360h80v-280q0-17 11.5-28.5T800-800q17 0 28.5 11.5T840-760v392q0 137-95.5 232.5T512-40Z'],
|
||||
backspace: [
|
||||
'M360-200q-20 0-37.5-9T294-234L153-434q-15-21-15-46t15-46l141-200q11-16 28.5-25t37.5-9h400q33 0 56.5 23.5T840-680v400q0 33-23.5 56.5T760-200H360Zm400-80v-400 400Zm-400 0h400v-400H360L218-480l142 200Zm200-144 76 76q11 11 28 11t28-11q11-11 11-28t-11-28l-76-76 76-76q11-11 11-28t-11-28q-11-11-28-11t-28 11l-76 76-76-76q-11-11-28-11t-28 11q-11 11-11 28t11 28l76 76-76 76q-11 11-11 28t11 28q11 11 28 11t28-11l76-76Z',
|
||||
'M360-200q-20 0-37.5-9T294-234L153-434q-15-21-15-46t15-46l141-200q11-16 28.5-25t37.5-9h400q33 0 56.5 23.5T840-680v400q0 33-23.5 56.5T760-200H360Zm200-224 76 76q11 11 28 11t28-11q11-11 11-28t-11-28l-76-76 76-76q11-11 11-28t-11-28q-11-11-28-11t-28 11l-76 76-76-76q-11-11-28-11t-28 11q-11 11-11 28t11 28l76 76-76 76q-11 11-11 28t11 28q11 11 28 11t28-11l76-76Z'],
|
||||
bookmark: [
|
||||
'm480-240-168 72q-40 17-76-6.5T200-241v-519q0-33 23.5-56.5T280-840h400q33 0 56.5 23.5T760-760v519q0 43-36 66.5t-76 6.5l-168-72Zm0-88 200 86v-518H280v518l200-86Zm0-432H280h400-200Z',
|
||||
'm480-240-168 72q-40 17-76-6.5T200-241v-519q0-33 23.5-56.5T280-840h400q33 0 56.5 23.5T760-760v519q0 43-36 66.5t-76 6.5l-168-72Z'],
|
||||
bottom_panel_close: [
|
||||
'm508-528 98-98q10-10 5-22t-19-12H368q-14 0-19 12t5 22l98 98q12 12 28 12t28-12Zm252-312q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560ZM200-320v120h560v-120H200Zm560-80v-360H200v360h560Zm-560 80v120-120Z',
|
||||
'm508-528 98-98q10-10 5-22t-19-12H368q-14 0-19 12t5 22l98 98q12 12 28 12t28-12Zm252-312q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560Zm0 440v-360H200v360h560Z'],
|
||||
bottom_panel_open: [
|
||||
'M368-500h224q14 0 19-12t-5-22l-98-98q-12-12-28-12t-28 12l-98 98q-10 10-5 22t19 12ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-200v120h560v-120H200Zm0-80h560v-360H200v360Zm0 80v120-120Z',
|
||||
'M368-500h224q14 0 19-12t-5-22l-98-98q-12-12-28-12t-28 12l-98 98q-10 10-5 22t19 12ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-280h560v-360H200v360Z'],
|
||||
closed_caption_disabled: [
|
||||
'm361-600 60 60H300v120h80q0-9 6-15t15-6h18q9 0 15 6t6 15v20q0 17-11.5 28.5T400-360H280q-17 0-28.5-11.5T240-400v-160q0-17 11.5-28.5T280-600h81Zm479-120v400q0 20-13 30.5T799-279q-15 0-27-10.5T760-320v-400H359q-20 0-30-12.5T319-760q0-15 10-27.5t30-12.5h401q33 0 56.5 23.5T840-720ZM720-400q0 9-3.5 17.5T706-369l-51-51h5q0-9 6-15t15-6h18q9 0 15 6t6 15v20Zm-40-200q17 0 28.5 11.5T720-560v20q0 9-6 15t-15 6h-18q-9 0-15-6t-6-15h-80v45l-60-60v-5q0-17 11.5-28.5T560-600h120Zm-122 82Zm-154 74ZM200-160q-33 0-56.5-23.5T120-240v-480q0-25 13.5-44.5T168-793l73 73h-41v480h407L55-791q-12-12-12-28.5T55-848q12-12 28.5-12t28.5 12l735 735q12 12 12 28.5T847-56q-12 12-28.5 12T790-56L687-160H200Z',
|
||||
'M791-57 687-160H200q-33 0-56.5-23.5T120-240v-487l-65-65q-12-12-12-28.5T55-849q12-12 28.5-12t28.5 12l736 736q12 12 12 28t-12 28q-12 12-28.5 12T791-57ZM280-360h120q17 0 28.5-11.5T440-400v-25q0-8-6-14t-14-6h-20q-8 0-14 6t-6 14v5h-80v-127l-45-45v1q-7 5-11 13t-4 18v160q0 17 11.5 28.5T280-360Zm560-360v388q0 27-24.5 37.5T772-303l-66-66q7-5 10.5-13.5T720-400v-20q0-8-6-14t-14-6h-20q-8 0-14 6t-6 14h-5l-75-75v-45h80v5q0 8 6 14t14 6h20q8 0 14-6t6-14v-25q0-17-11.5-28.5T680-600H560q-17 0-28.5 11.5T520-560v5L343-732q-19-19-8.5-43.5T372-800h388q33 0 56.5 23.5T840-720Z'],
|
||||
@@ -107,6 +123,9 @@
|
||||
comment: [
|
||||
'M280-400h400q17 0 28.5-11.5T720-440q0-17-11.5-28.5T680-480H280q-17 0-28.5 11.5T240-440q0 17 11.5 28.5T280-400Zm0-120h400q17 0 28.5-11.5T720-560q0-17-11.5-28.5T680-600H280q-17 0-28.5 11.5T240-560q0 17 11.5 28.5T280-520Zm0-120h400q17 0 28.5-11.5T720-680q0-17-11.5-28.5T680-720H280q-17 0-28.5 11.5T240-680q0 17 11.5 28.5T280-640ZM160-240q-33 0-56.5-23.5T80-320v-480q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v623q0 27-24.5 37.5T812-148l-92-92H160Zm594-80 46 45v-525H160v480h594Zm-594 0v-480 480Z',
|
||||
'M280-400h400q17 0 28.5-11.5T720-440q0-17-11.5-28.5T680-480H280q-17 0-28.5 11.5T240-440q0 17 11.5 28.5T280-400Zm0-120h400q17 0 28.5-11.5T720-560q0-17-11.5-28.5T680-600H280q-17 0-28.5 11.5T240-560q0 17 11.5 28.5T280-520Zm0-120h400q17 0 28.5-11.5T720-680q0-17-11.5-28.5T680-720H280q-17 0-28.5 11.5T240-680q0 17 11.5 28.5T280-640ZM160-240q-33 0-56.5-23.5T80-320v-480q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v623q0 27-24.5 37.5T812-148l-92-92H160Z'],
|
||||
diamond: [
|
||||
'M480-165q-17 0-33-7.5T419-194L113-560q-9-11-13.5-24T95-611q0-9 1.5-18.5T103-647l75-149q11-20 29.5-32t41.5-12h462q23 0 41.5 12t29.5 32l75 149q5 8 6.5 17.5T865-611q0 14-4.5 27T847-560L541-194q-12 14-28 21.5t-33 7.5Zm-95-475h190l-60-120h-70l-60 120Zm55 347v-267H218l222 267Zm80 0 222-267H520v267Zm144-347h106l-60-120H604l60 120Zm-474 0h106l60-120H250l-60 120Z',
|
||||
'M368-630h224L486-840h-12L368-630Zm82 474v-414H105l345 414Zm60 0 345-414H510v414Zm148-474h207l-83-166q-11-20-29.5-32T711-840H554l104 210Zm-563 0h207l104-210H249q-23 0-41.5 12T178-796L95-630Z'],
|
||||
drag_handle: [
|
||||
'M200-360q-17 0-28.5-11.5T160-400q0-17 11.5-28.5T200-440h560q17 0 28.5 11.5T800-400q0 17-11.5 28.5T760-360H200Zm0-160q-17 0-28.5-11.5T160-560q0-17 11.5-28.5T200-600h560q17 0 28.5 11.5T800-560q0 17-11.5 28.5T760-520H200Z',
|
||||
'M200-360q-17 0-28.5-11.5T160-400q0-17 11.5-28.5T200-440h560q17 0 28.5 11.5T800-400q0 17-11.5 28.5T760-360H200Zm0-160q-17 0-28.5-11.5T160-560q0-17 11.5-28.5T200-600h560q17 0 28.5 11.5T800-560q0 17-11.5 28.5T760-520H200Z'],
|
||||
@@ -122,6 +141,9 @@
|
||||
explore: [
|
||||
'M480-440q-17 0-28.5-11.5T440-480q0-17 11.5-28.5T480-520q17 0 28.5 11.5T520-480q0 17-11.5 28.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320ZM297-277l250-117q6-3 11-8t8-11l117-250q5-10-2.5-17.5T663-683L413-566q-6 3-11 8t-8 11L277-297q-5 10 2.5 17.5T297-277Z',
|
||||
'M480-440q-17 0-28.5-11.5T440-480q0-17 11.5-28.5T480-520q17 0 28.5 11.5T520-480q0 17-11.5 28.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0 0q-134 0-227-93t-93-227q0-134 93-227t227-93q134 0 227 93t93 227q0 134-93 227t-227 93Zm67-234q6-3 11-8t8-11l117-250q5-10-2.5-17.5T663-683L413-566q-6 3-11 8t-8 11L277-297q-5 10 2.5 17.5T297-277l250-117Z'],
|
||||
fast_forward: [
|
||||
'M100-315v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T428-480q0 10-4.5 18.5T410-447L162-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Zm400 0v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T828-480q0 10-4.5 18.5T810-447L562-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29ZM180-480Zm400 0Zm-400 90 136-90-136-90v180Zm400 0 136-90-136-90v180Z',
|
||||
'M100-315v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T428-480q0 10-4.5 18.5T410-447L162-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Zm400 0v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T828-480q0 10-4.5 18.5T810-447L562-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Z'],
|
||||
favorite: [
|
||||
'M480-147q-14 0-28.5-5T426-168l-69-63q-106-97-191.5-192.5T80-634q0-94 63-157t157-63q53 0 100 22.5t80 61.5q33-39 80-61.5T660-854q94 0 157 63t63 157q0 115-85 211T602-230l-68 62q-11 11-25.5 16t-28.5 5Zm-38-543q-29-41-62-62.5T300-774q-60 0-100 40t-40 100q0 52 37 110.5T285.5-410q51.5 55 106 103t88.5 79q34-31 88.5-79t106-103Q726-465 763-523.5T800-634q0-60-40-100t-100-40q-47 0-80 21.5T518-690q-7 10-17 15t-21 5q-11 0-21-5t-17-15Zm38 189Z',
|
||||
'M480-147q-14 0-28.5-5T426-168l-69-63q-106-97-191.5-192.5T80-634q0-94 63-157t157-63q53 0 100 22.5t80 61.5q33-39 80-61.5T660-854q94 0 157 63t63 157q0 115-85 211T602-230l-68 62q-11 11-25.5 16t-28.5 5Z'],
|
||||
@@ -182,6 +204,9 @@
|
||||
notifications_active: [
|
||||
'M200-200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-83 50-147.5T420-792v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 84.5T720-560v280h40q17 0 28.5 11.5T800-240q0 17-11.5 28.5T760-200H200Zm280-300Zm0 420q-33 0-56.5-23.5T400-160h160q0 33-23.5 56.5T480-80ZM320-280h320v-280q0-66-47-113t-113-47q-66 0-113 47t-47 113v280ZM120-560q-17 0-28.5-13T82-603q8-75 42-139.5T211-855q13-11 29.5-10t26.5 15q10 14 8 30t-15 28q-39 37-64 86t-33 106q-2 17-14 28.5T120-560Zm720 0q-17 0-29-11.5T797-600q-8-57-33-106t-64-86q-13-12-15-28t8-30q10-14 26.5-15t29.5 10q53 48 87 112.5T878-603q2 17-9.5 30T840-560Z',
|
||||
'M200-200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-83 50-147.5T420-792v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 84.5T720-560v280h40q17 0 28.5 11.5T800-240q0 17-11.5 28.5T760-200H200ZM480-80q-33 0-56.5-23.5T400-160h160q0 33-23.5 56.5T480-80ZM120-560q-17 0-28.5-13T82-603q8-75 42-139.5T211-855q13-11 29.5-10t26.5 15q10 14 8 30t-15 28q-39 37-64 86t-33 106q-2 17-14 28.5T120-560Zm720 0q-17 0-29-11.5T797-600q-8-57-33-106t-64-86q-13-12-15-28t8-30q10-14 26.5-15t29.5 10q53 48 87 112.5T878-603q2 17-9.5 30T840-560Z'],
|
||||
notifications_off: [
|
||||
'M646-200H200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-33 8.5-65t25.5-61l60 60q-7 16-10.5 32.5T320-560v280h248L84-764q-11-11-11-28t11-28q11-11 28-11t28 11l680 680q11 11 11.5 27.5T820-84q-11 11-28 11t-28-11L646-200ZM540-792q80 20 130 84.5T720-560v110q0 20-12.5 30T680-410q-15 0-27.5-10.5T640-451v-109q0-66-47-113t-113-47q-16 0-34 4t-32 10q-17 7-33.5 3T355-722q-8-13-5.5-27.5T365-771q13-7 27-12t28-9v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28Zm-96 388Zm36 324q-30 0-53.5-16.5T403-141q0-8 6.5-13.5T424-160h112q8 0 14.5 5.5T557-141q0 28-23.5 44.5T480-80Zm33-481Z',
|
||||
'M646-200H200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-33 8.5-65t25.5-61l126 126H288L84-764q-11-11-11-28t11-28q11-11 28-11t28 11l680 680q11 11 11.5 27.5T820-84q-11 11-28 11t-28-11L646-200Zm74-251q0 12-7 22t-18 15q-11 5-23 2.5T652-422L367-707q-7-7-10-15t-3-17q0-11 5.5-21.5T375-776q11-5 22-9t23-7v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 85t50 147v109ZM480-80q-30 0-53.5-16.5T403-141q0-8 6.5-13.5T424-160h112q8 0 14.5 5.5T557-141q0 28-23.5 44.5T480-80Z'],
|
||||
open_in_full: [
|
||||
'M160-120q-17 0-28.5-11.5T120-160v-240q0-17 11.5-28.5T160-440q17 0 28.5 11.5T200-400v144l504-504H560q-17 0-28.5-11.5T520-800q0-17 11.5-28.5T560-840h240q17 0 28.5 11.5T840-800v240q0 17-11.5 28.5T800-520q-17 0-28.5-11.5T760-560v-144L256-200h144q17 0 28.5 11.5T440-160q0 17-11.5 28.5T400-120H160Z',
|
||||
'M160-120q-17 0-28.5-11.5T120-160v-240q0-17 11.5-28.5T160-440q17 0 28.5 11.5T200-400v144l504-504H560q-17 0-28.5-11.5T520-800q0-17 11.5-28.5T560-840h240q17 0 28.5 11.5T840-800v240q0 17-11.5 28.5T800-520q-17 0-28.5-11.5T760-560v-144L256-200h144q17 0 28.5 11.5T440-160q0 17-11.5 28.5T400-120H160Z'],
|
||||
@@ -359,5 +384,17 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
ns.vrSymbols = { draw, has: name => name in PATHS, NAMES: Object.keys(PATHS) };
|
||||
// An icon given as a name, or as { name, color (a colour spec: tokens.js resolve, e.g.
|
||||
// 'primary' or '#ff0033'), filled }: drawn like draw(), in its own colour when it has one
|
||||
// (e.g. a site's mark that keeps its brand colour in a menu row).
|
||||
function drawIcon(g, icon, cx, cy, size, filled = false) {
|
||||
if (!icon || typeof icon === 'string') return draw(g, icon, cx, cy, size, filled);
|
||||
g.save();
|
||||
if (icon.color) g.fillStyle = ns.vrTokens.resolve(icon.color);
|
||||
const ok = draw(g, icon.name, cx, cy, size, icon.filled != null ? !!icon.filled : filled);
|
||||
g.restore();
|
||||
return ok;
|
||||
}
|
||||
|
||||
ns.vrSymbols = { draw, drawIcon, has: name => name in PATHS, NAMES: Object.keys(PATHS) };
|
||||
})(globalThis.__streamxr = globalThis.__streamxr || {});
|
||||
@@ -19,7 +19,7 @@
|
||||
// whatever the site's panels use,
|
||||
// state() -> { title, time, duration, paused,
|
||||
// nowPlaying (what the controls panel shows: see panel-ui.js),
|
||||
// quality: { current, currentLabel, levels: [{ id, label }] },
|
||||
// quality: { current, currentLabel, levels: [{ id, label, icons?, disabled? }] },
|
||||
// live: null | { atHead, behind (seconds), dvr } (a live stream; time
|
||||
// and duration then cover only the rewindable part),
|
||||
// speed, speeds: [rates], volume: { level, muted },
|
||||
@@ -27,7 +27,7 @@
|
||||
// intensity (0..1) }] (how much each part is rewatched; optional),
|
||||
// ad: null | { ... },
|
||||
// format: { current, options: [{ id, label, short, disabled }] },
|
||||
// extras: { left: [...], right: [...] } (the site's own buttons on
|
||||
// extras: { left: [...], right: [...], seek } (the site's own buttons on
|
||||
// the controls panel: panel-ui.js, plus menu: null | { items: [{ id,
|
||||
// text, on, disabled }], note, scroll, width, stay (stays open on a
|
||||
// choice) }: opens like Quality's),
|
||||
@@ -127,8 +127,14 @@
|
||||
close: () => { if (isOpen(reg.id)) toggleOpen(reg.id); },
|
||||
isOpen: () => isOpen(reg.id),
|
||||
closeMenu: () => { menu = null; },
|
||||
panel: id => controller(id), // another of the site's panels' controllers (e.g. to show something in it)
|
||||
// Open (or close, if it's open) a floating menu on one of this panel's controls
|
||||
// (`local`, e.g. 'action:bell'; the style needs rectOf). Its items come from the
|
||||
// controller's menu(local) each frame; a choice arrives as { type: 'menu', value:
|
||||
// item id, menu: local }.
|
||||
openMenu: local => { const id = `p:${reg.id}${SEP}${local}`; menu = menu === id ? null : id; lastActivity = performance.now(); },
|
||||
confirm: d => confirm(d),
|
||||
typeInto: on => typeInto(p, on),
|
||||
typeInto: (on, opts) => typeInto(p, on, opts),
|
||||
});
|
||||
panels.set(reg.id, p);
|
||||
}
|
||||
@@ -146,12 +152,18 @@
|
||||
// (Typing into the top panel's bar needs only the panels shown: the bar is always there.)
|
||||
const typingNow = () => !!typing && (typing.zone === 'top' ? visible : shown(typing));
|
||||
// A panel asks for the keys (on) or gives them back. Asking also brings up the in-VR
|
||||
// keyboard if it isn't up; giving them back puts it away again if it came up that way.
|
||||
// keyboard if it isn't up; giving them back (with no other panel typing) puts it away again
|
||||
// if it came up that way. A panel asking while another has the keys takes them over, and
|
||||
// the other is sent Escape (as for a click on nothing), so it stops typing too (its caret
|
||||
// goes). opts.emojis: the panel's own emojis for the keyboard's emoji pages (keyboard.js setEmojis).
|
||||
let kbAuto = false; // the keyboard came up because a panel asked for keys
|
||||
function typeInto(p, on) {
|
||||
function typeInto(p, on, opts = {}) {
|
||||
const before = typing;
|
||||
typing = on ? p : (typing === p ? null : typing);
|
||||
if (on && before && before !== p) before.ctrl.event({ type: 'key', value: 'Escape' }); // (its giving back changes nothing now)
|
||||
if (on || !typing) kb.setEmojis(on ? opts.emojis || [] : []);
|
||||
if (on && !kbShown) { showKeyboard(true); kbAuto = true; }
|
||||
else if (!on && kbAuto && kbShown) { showKeyboard(false); kbAuto = false; }
|
||||
else if (!on && !typing && kbAuto && kbShown) { showKeyboard(false); kbAuto = false; }
|
||||
}
|
||||
const isTypingKey = e => !e.ctrlKey && !e.metaKey && !e.altKey && (e.key.length === 1 || TYPING_KEYS.test(e.key));
|
||||
function onKey(e) {
|
||||
@@ -168,6 +180,12 @@
|
||||
const SEP = '|';
|
||||
const panelOf = id => (id && id.includes(SEP) ? panels.get(id.slice(0, id.indexOf(SEP))) : null);
|
||||
const localOf = (id, p) => (id && id.startsWith(p.reg.id + SEP) ? id.slice(p.reg.id.length + 1) : null);
|
||||
// A side panel's menu id ('p:<panel>|<control>', host.openMenu) -> { p, local } or null.
|
||||
function panelMenu(id) {
|
||||
if (!id || !id.startsWith('p:')) return null;
|
||||
const rest = id.slice(2), i = rest.indexOf(SEP), p = i < 0 ? null : panels.get(rest.slice(0, i));
|
||||
return p ? { p, local: rest.slice(i + 1) } : null;
|
||||
}
|
||||
// Tell a panel's controller one of its controls was used: '<type>:<value>' -> event.
|
||||
function sendEvent(p, local, extra) {
|
||||
const i = local.indexOf(':');
|
||||
@@ -220,7 +238,18 @@
|
||||
// Where the menu goes: its bottom middle just above the button that opened it (centred
|
||||
// on it, kept from reaching far past the panel's sides), in the controls panel's tilt,
|
||||
// 32 dp in front.
|
||||
// A side panel's menu ('p:<panel>|<control>') goes the same way above its control, in
|
||||
// that panel's pose.
|
||||
function menuModel(id) {
|
||||
const pm = panelMenu(id);
|
||||
if (pm) {
|
||||
const r = pm.p.style.rectOf && pm.p.style.rectOf(pm.local), c = pm.p.style.canvas, sz = pm.p.size;
|
||||
if (!r) return null;
|
||||
const half = Math.max(0, sz[0] / 2 - 0.12);
|
||||
const x = Math.max(-half, Math.min(half, ((r.x + r.w / 2) / c.width - 0.5) * sz[0]));
|
||||
const bottom = (0.5 - r.y / c.height) * sz[1] + 12 * MPX;
|
||||
return M.multiply(panelModel(pm.p), M.translation(x, bottom + menuSize[1] / 2, dp(32)));
|
||||
}
|
||||
const rect = ui.rectOf(id);
|
||||
if (!rect) return null;
|
||||
const cx = ((rect.x + rect.w / 2) / ui.width - 0.5) * size[0];
|
||||
@@ -243,7 +272,8 @@
|
||||
for (const side of ['left', 'right']) {
|
||||
const list = [...panels.values()].filter(p => p.zone === side);
|
||||
if (!list.length) continue;
|
||||
const tb = ns.vrOrbitersUI.createToolbar(list.map(itemOf), side);
|
||||
// (with a grab bar on its video side, 'sgrab:<side>': gripping it moves the whole side, moveSide)
|
||||
const tb = ns.vrOrbitersUI.createToolbar(list.map(itemOf), side, { handle: { id: `sgrab:${side}` } });
|
||||
orbiters[side] = { ui: tb, pass: ns.vrPanelPass.createPanelPass(gl, vaos, tb.canvas), size: metres(tb.canvas), key: '' };
|
||||
}
|
||||
const popupPanel = [...panels.values()].find(p => p.zone === 'controls') || null;
|
||||
@@ -283,8 +313,43 @@
|
||||
if (z === 'top') return; // its controller decides (model().open)
|
||||
open[z] = open[z] === which ? null : which;
|
||||
if (OPEN_KEY[z]) ns.storage.set(OPEN_KEY[z], open[z] || 'none');
|
||||
replaced[z] = undefined; // (your choice now: nothing to bring back)
|
||||
}
|
||||
// Shown: open while the panels are up, or open and registered to stay while they're hidden.
|
||||
const shown = p => isOpen(p.reg.id) && (visible || !!p.reg.stays);
|
||||
|
||||
// A side panel offered only some of the time (its registration's `available(st)`, asked
|
||||
// every frame; e.g. a live stream's chat): while it isn't, its toolbar item is hidden and it
|
||||
// can't be open. One registered `autoOpen` opens whenever it becomes available (not
|
||||
// remembered); the panel it replaced comes back when it goes (else the side's default).
|
||||
const replaced = { left: undefined, right: undefined }; // per side: the panel an autoOpen one replaced
|
||||
const availableNow = p => p.available !== false;
|
||||
const fallbackOf = side => { const d = [...panels.values()].find(p => p.zone === side && p.reg.default && availableNow(p)); return d ? d.reg.id : null; };
|
||||
function refreshAvailable(st) {
|
||||
let changed = false;
|
||||
for (const p of panels.values()) {
|
||||
if (!p.reg.available || (p.zone !== 'left' && p.zone !== 'right')) continue;
|
||||
let a = false;
|
||||
try { a = !!p.reg.available(st); } catch (_) { /* treated as unavailable */ }
|
||||
if (a === p.available) continue;
|
||||
p.available = a; changed = true;
|
||||
const z = p.zone, id = p.reg.id;
|
||||
if (a && p.reg.autoOpen && open[z] !== id) { replaced[z] = open[z]; open[z] = id; }
|
||||
else if (!a && open[z] === id) {
|
||||
const back = replaced[z];
|
||||
open[z] = back === null || (back && back !== id && availableNow(panels.get(back))) ? back : fallbackOf(z);
|
||||
replaced[z] = undefined;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
for (const side of ['left', 'right']) {
|
||||
const o = orbiters[side];
|
||||
if (!o) continue;
|
||||
const hidden = {};
|
||||
for (const p of panels.values()) if (p.zone === side && !availableNow(p)) hidden[`toggle:${p.reg.id}`] = true;
|
||||
if (o.ui.setHidden(hidden)) { laidOut = -1; o.key = ''; } // the bar's length changed: lay it out again (a moved side stays moved)
|
||||
}
|
||||
}
|
||||
const shown = p => visible && isOpen(p.reg.id);
|
||||
|
||||
// ------------------------------------------------------------ the keyboard
|
||||
// keyboard.js's keys, standing in for the controls panel: while it's up (toggleKeyboard,
|
||||
@@ -343,6 +408,7 @@
|
||||
let model = M.identity(); // the controls panel's pose in the world (centre; +Z faces you)
|
||||
let visible = cfg.showOnStart; // are the panels shown (toggled by the left D-pad, auto-hide)
|
||||
let lastActivity = 0; // when the laser last touched them (for auto-hide)
|
||||
let dismissedAt = -1; // when a trigger press on nothing hid everything (-1: not hidden so; see update)
|
||||
const hands = new Map(); // handedness -> { hover, trigger, grip, beam, scrollPanel, pagePanel }
|
||||
let pressed = null; // { hand, id } while the trigger is held on a control
|
||||
let scrub = null; // { hand, time } while dragging on the seek bar
|
||||
@@ -351,6 +417,7 @@
|
||||
let textDrag = null; // { hand, p, v, scroll, moved, last, at, vel } while a trigger drags a scrolling area
|
||||
let fling = null; // { p, pos, vel (px/s) } while a let-go drag keeps scrolling (stepFling)
|
||||
let grab = null; // { hand, local } while moving the panel by its grab bar
|
||||
let sideGrab = null; // { hand, side, local, at } while moving a side by its toolbar's handle (moveSide)
|
||||
let menu = null; // null | 'quality' | 'speed' | 'format' | 'volume' | 'x:<id>' (a site button's)
|
||||
let extrasNow = null; // the site's own buttons, from the latest state (st.extras)
|
||||
let menuDrag = null; // { hand, v, scroll, moved } while a trigger drags a scrolling menu
|
||||
@@ -387,14 +454,23 @@
|
||||
const sides = { left: null, right: null }; // per side: { bar (the toolbar's pose), barYaw }
|
||||
let laidOut = -1; // the zone version they were laid out for
|
||||
let searchBarModel = M.identity(); // the search bar's pose (with a top panel)
|
||||
// What you moved by a grab bar: the move (a rotation and translation) applied on top of
|
||||
// the laid-out pose, for the controls panel (with the keyboard, the pill and its popup) and
|
||||
// for each side (its toolbar and every panel on it together). Laying out again keeps them;
|
||||
// a recenter carries them along (place); only the global reset drops them (resetSettings).
|
||||
const sideMove = { left: null, right: null };
|
||||
let controlsMove = null, laidControls = M.identity(); // the controls panel's move; its laid-out pose
|
||||
const moved = (side, m) => (sideMove[side] ? M.multiply(sideMove[side], m) : m);
|
||||
function relayout() {
|
||||
const Z = ns.vrSafeZone, z = Z.angles(), eye = Z.anchor.pos;
|
||||
faceToward(Z.point(0, z.bottom - GAP - halfDeg(size[1]), D), eye);
|
||||
laidControls = facing(Z.point(0, z.bottom - GAP - halfDeg(size[1]), D), eye);
|
||||
model = controlsMove ? M.multiply(controlsMove, laidControls) : laidControls;
|
||||
for (const side of ['left', 'right']) {
|
||||
const o = orbiters[side];
|
||||
if (!o) continue;
|
||||
const sgn = side === 'left' ? -1 : 1, edge = side === 'left' ? z.left : z.right;
|
||||
const barYaw = edge + sgn * (GAP / 2 + halfDeg(o.ui.barWidth * MPX));
|
||||
// (out by the grab bar's room too: it stands between the zone's edge and the toolbar)
|
||||
const barYaw = edge + sgn * (GAP / 2 + halfDeg(o.ui.barWidth * MPX) + 2 * halfDeg((o.ui.handleRoom || 0) * MPX));
|
||||
// The toolbar's canvas, moved so its bar (not the canvas's centre) is at barYaw.
|
||||
const shift = M.translation(-(o.ui.barCentre.x - o.ui.canvas.width / 2) * MPX, (o.ui.barCentre.y - o.ui.canvas.height / 2) * MPX, 0);
|
||||
sides[side] = { bar: M.multiply(facing(Z.point(barYaw, z.mid, D - dp(16)), eye), shift), barYaw, pitch: z.mid };
|
||||
@@ -441,25 +517,45 @@
|
||||
const pillButtonH = () => orbiters.pill.ui.buttonHeight * MPX;
|
||||
const pillModel = () => M.multiply(model, M.translation(0, size[1] / 2 - dp(12) + pillButtonH() / 2, dp(16)));
|
||||
const popupModel = p => M.multiply(model, M.translation(0, size[1] / 2 - dp(12) + pillButtonH() + dp(8) + p.size[1] / 2, dp(32)));
|
||||
// Where a panel is: on its side, or the popup.
|
||||
const panelModel = p => (p.zone === 'controls' ? popupModel(p) : p.pose);
|
||||
// Where an orbiter is: a side's toolbar, the pill or the search bar.
|
||||
const orbiterModel = name => (name === 'pill' ? pillModel() : name === 'bar' ? searchBarModel : sides[name].bar);
|
||||
// Where a panel is: on its side (as moved), or the popup.
|
||||
const panelModel = p => (p.zone === 'controls' ? popupModel(p) : p.zone === 'left' || p.zone === 'right' ? moved(p.zone, p.pose) : p.pose);
|
||||
// Where an orbiter is: a side's toolbar (as moved), the pill or the search bar.
|
||||
const orbiterModel = name => (name === 'pill' ? pillModel() : name === 'bar' ? searchBarModel : moved(name, sides[name].bar));
|
||||
// Move a side by its toolbar's handle: the point grabbed (on the handle) follows the hand
|
||||
// and the side turns to face you there; its toolbar and every panel on it come along.
|
||||
// (g.local: the grabbed point in the hand's space; g.at: the same point in the toolbar's
|
||||
// laid-out space, so the side turns about it.)
|
||||
function moveSide(g, point, head) {
|
||||
const s = sides[g.side];
|
||||
if (!s) return;
|
||||
const held = M.multiply(facing(point, head), M.translation(-g.at[0], -g.at[1], -g.at[2])); // where the toolbar goes
|
||||
sideMove[g.side] = M.multiply(held, M.invertRigid(s.bar));
|
||||
}
|
||||
|
||||
// Lay everything out in front of you: on entering VR and whenever you recenter. `yaw`
|
||||
// is the direction you're facing; the Safe Zone is measured from here.
|
||||
// is the direction you're facing; the Safe Zone is measured from here. What you moved
|
||||
// (the controls panel, the sides) comes along: its move is turned and shifted as the
|
||||
// recenter turned and shifted the layout, so it stays where it was relative to you. (With
|
||||
// Settings → Safe zone → "Recenter resets panels" on, the moves are dropped instead.)
|
||||
const anchorFrame = () => { const a = ns.vrSafeZone.anchor; return M.multiply(M.translation(a.pos[0], a.pos[1], a.pos[2]), M.rotationY(a.yaw)); };
|
||||
function place(head, yaw) {
|
||||
const before = anchorFrame();
|
||||
ns.vrSafeZone.setAnchor(head, yaw);
|
||||
const turn = M.multiply(anchorFrame(), M.invertRigid(before)); // the old anchor's frame -> the new one's
|
||||
const drop = ns.vrSafeZoneOptions && ns.vrSafeZoneOptions.recenterResets();
|
||||
const carry = mv => (mv && !drop ? M.multiply(turn, M.multiply(mv, M.invertRigid(turn))) : null);
|
||||
sideMove.left = carry(sideMove.left); sideMove.right = carry(sideMove.right); controlsMove = carry(controlsMove);
|
||||
relayout();
|
||||
lastActivity = performance.now(); // don't auto-hide right after placing
|
||||
}
|
||||
|
||||
// The global reset: the keyboard back to its default size (forgetting yours), any move
|
||||
// or resize dropped, and the panels shown. Their place comes from the recenter that
|
||||
// follows (the controls panel has no remembered place: the layout puts it back).
|
||||
// or resize dropped (the controls panel and the sides back in the layout's places), and
|
||||
// the panels shown. Their place comes from the recenter that follows.
|
||||
function resetSettings() {
|
||||
kbScale = kbCfg.scale; ns.storage.remove(KB_SCALE_KEY);
|
||||
kbGrab = null; grab = null; menu = null;
|
||||
kbGrab = null; grab = null; sideGrab = null; menu = null;
|
||||
controlsMove = null; sideMove.left = sideMove.right = null;
|
||||
visible = true;
|
||||
lastActivity = performance.now();
|
||||
}
|
||||
@@ -469,24 +565,27 @@
|
||||
function toggleVisible() {
|
||||
if (kbShown) { showKeyboard(false); return; }
|
||||
visible = !visible;
|
||||
if (!visible) { menu = null; grab = null; }
|
||||
if (!visible) { menu = null; grab = null; sideGrab = null; }
|
||||
lastActivity = performance.now();
|
||||
}
|
||||
|
||||
// Grip belongs to us (not recenter) while that hand points at or holds something grabbable.
|
||||
const isSideGrab = id => !!id && id.startsWith('sgrab:');
|
||||
const claimsGrip = hand => {
|
||||
const h = hands.get(hand);
|
||||
return !!(grab && grab.hand === hand) || !!(targetGrab && targetGrab.hand === hand) || !!(kbGrab && kbGrab.hand === hand) ||
|
||||
!!(h && ((visible && h.hover === 'grab') || h.targetHit || (kbShown && isKbHandle(h.hover))));
|
||||
return !!(grab && grab.hand === hand) || !!(sideGrab && sideGrab.hand === hand) || !!(targetGrab && targetGrab.hand === hand) || !!(kbGrab && kbGrab.hand === hand) ||
|
||||
!!(h && ((visible && (h.hover === 'grab' || isSideGrab(h.hover))) || h.targetHit || (kbShown && isKbHandle(h.hover))));
|
||||
};
|
||||
|
||||
// The stick belongs to us (scrolling, not ±10 s) while that hand points at a scrolling
|
||||
// area; iconSwaps() tells the controller models to show the scroll icon there.
|
||||
const stickScrolls = hand => { const h = hands.get(hand); return !!(visible && h && (h.scrollPanel || h.menuScroll)); };
|
||||
// area; iconSwaps() tells the controller models to show the scroll icon there. (A panel
|
||||
// that stays while the rest is hidden, e.g. a live chat, counts then too.)
|
||||
const reachable = p => !!p && (visible || !!p.reg.stays);
|
||||
const stickScrolls = hand => { const h = hands.get(hand); return !!(h && (reachable(h.scrollPanel) || (visible && h.menuScroll))); };
|
||||
// Likewise its left / right turns pages while it points at a panel showing page arrows
|
||||
// (h.pagePanel, set in update). stickPage(hand, dir) presses that panel's previous
|
||||
// (dir < 0) / next arrow if it's enabled; true if the stick was ours.
|
||||
const stickPages = hand => { const h = hands.get(hand); return !!(visible && h && h.pagePanel); };
|
||||
const stickPages = hand => { const h = hands.get(hand); return !!(h && reachable(h.pagePanel)); };
|
||||
function stickPage(hand, dir) {
|
||||
if (!stickPages(hand)) return false;
|
||||
const p = hands.get(hand).pagePanel, id = dir < 0 ? 'page:prev' : 'page:next';
|
||||
@@ -500,6 +599,10 @@
|
||||
else if (stickPages(hand)) out[hand] = { seek: 'page' };
|
||||
// the keyboard button shows it puts the keyboard away while it's up
|
||||
if (kbShown) out[hand] = { ...(out[hand] || {}), keyboard: 'keyboardhide' };
|
||||
// the grip shows a hand while it would grab (or is holding) something instead of recentering
|
||||
if (claimsGrip(hand)) out[hand] = { ...(out[hand] || {}), recenter: 'grab' };
|
||||
// the panels button: "close" while they're up, "open" while they're hidden
|
||||
if (!visible) out[hand] = { ...(out[hand] || {}), panel: 'panelopen' };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -560,6 +663,13 @@
|
||||
function click(id) {
|
||||
const p = panelOf(id);
|
||||
if (p) { sendEvent(p, localOf(id, p)); return; }
|
||||
if (id.startsWith('pi:')) { // an item of a side panel's menu: 'pi:<index>'
|
||||
const pm = panelMenu(menu), mm = pm && pm.p.ctrl.menu ? pm.p.ctrl.menu(pm.local) : null;
|
||||
const it = mm && (mm.items || [])[+id.slice(3)];
|
||||
if (it && !it.disabled) pm.p.ctrl.event({ type: 'menu', value: it.id, menu: pm.local });
|
||||
if (!(mm && mm.stay)) menu = null;
|
||||
return;
|
||||
}
|
||||
if (id.startsWith('toggle:')) {
|
||||
const pid = id.slice(7);
|
||||
toggleOpen(pid);
|
||||
@@ -663,6 +773,7 @@
|
||||
extrasNow = st.extras || null;
|
||||
const headPos = [head.x, head.y, head.z];
|
||||
const frameInfo = { st, now };
|
||||
refreshAvailable(st); // (before the ticks: a panel just gone is closed)
|
||||
for (const p of panels.values()) if (p.ctrl.tick) p.ctrl.tick(frameInfo);
|
||||
|
||||
lastHead = headPos;
|
||||
@@ -692,6 +803,7 @@
|
||||
if (menuDrag && menuDrag.hand === hand) menuDrag = null;
|
||||
if (textDrag && textDrag.hand === hand) textDrag = null;
|
||||
if (grab && grab.hand === hand) grab = null;
|
||||
if (sideGrab && sideGrab.hand === hand) sideGrab = null;
|
||||
if (targetGrab && targetGrab.hand === hand) { targetGrab.target.grabEnd(headPos); targetGrab = null; }
|
||||
if (h.smoother) h.smoother.reset(); // tracking lost: start afresh, don't glide from the old pose
|
||||
continue;
|
||||
@@ -706,10 +818,16 @@
|
||||
|
||||
// Moving the panel: it keeps its offset from the controller and turns to face you.
|
||||
if (grab && grab.hand === hand) {
|
||||
if (grip) faceToward(M.transformPoint(m, grab.local), headPos);
|
||||
if (grip) { faceToward(M.transformPoint(m, grab.local), headPos); controlsMove = M.multiply(model, M.invertRigid(laidControls)); }
|
||||
else { grab = null; buzz(src, 0.3, 15); }
|
||||
lastActivity = now;
|
||||
}
|
||||
// Moving a side by its grab bar: the same, for the side's open panel and everything on it.
|
||||
if (sideGrab && sideGrab.hand === hand) {
|
||||
if (grip) moveSide(sideGrab, M.transformPoint(m, sideGrab.local), headPos);
|
||||
else { sideGrab = null; buzz(src, 0.3, 15); }
|
||||
lastActivity = now;
|
||||
}
|
||||
// Dragging another target (e.g. resizing or moving the cinema screen).
|
||||
if (targetGrab && targetGrab.hand === hand) {
|
||||
if (grip) targetGrab.target.grabMove(m, origin, dir, headPos);
|
||||
@@ -822,6 +940,13 @@
|
||||
} else if (grip && !h.grip && hover === 'grab' && !grab) {
|
||||
grab = { hand, local: toLocal(m, [model[12], model[13], model[14]]) };
|
||||
buzz(src, 0.5, 25);
|
||||
} else if (grip && !h.grip && isSideGrab(hover) && !sideGrab && surface && surface.startsWith('orbit:')) {
|
||||
// A side's move handle (on its toolbar): the point grabbed held where it is relative to the hand.
|
||||
const side = hover.slice(6), point = [origin[0] + dir[0] * hit.t, origin[1] + dir[1] * hit.t, origin[2] + dir[2] * hit.t];
|
||||
if (sides[side]) {
|
||||
sideGrab = { hand, side, local: toLocal(m, point), at: M.transformPoint(M.invertRigid(orbiterModel(side)), point) };
|
||||
buzz(src, 0.5, 25);
|
||||
}
|
||||
} else if (grip && !h.grip && isKbHandle(hover) && !kbGrab) {
|
||||
const id = hover.slice(3);
|
||||
if (id === 'grab') grab = { hand, local: toLocal(m, [model[12], model[13], model[14]]) }; // moves the shared place
|
||||
@@ -837,14 +962,18 @@
|
||||
// what was last clicked: typing stops (the panel gets Escape, which puts away a
|
||||
// keyboard that came up for it), an open menu on the controls panel closes, and a
|
||||
// dialog is cancelled (anywhere off it counts, as with M3's scrim).
|
||||
// With nothing to let go of (and no keyboard up), the press hides everything at once:
|
||||
// the panels, the side toolbars and the search bar, as auto-hide would (paused too).
|
||||
// With everything hidden (auto-hide, or the left D-pad), a trigger pull brings the
|
||||
// panels back, and the lasers with them (they only show while the panels do).
|
||||
if (trigger && !h.trigger) fling = null; // any press stops a scroll that keeps going
|
||||
if (trigger && !h.trigger && !visible && !tHit) { visible = true; lastActivity = now; buzz(src, 0.3, 15); }
|
||||
if (trigger && !h.trigger && !visible && !tHit) { visible = true; lastActivity = now; dismissedAt = -1; buzz(src, 0.3, 15); }
|
||||
else if (trigger && !h.trigger && !onPanel && !tHit) {
|
||||
const holding = !!(typing || menu || dialog || kbShown);
|
||||
if (typing) { lastActivity = now; typing.ctrl.event({ type: 'key', value: 'Escape' }); }
|
||||
menu = null;
|
||||
closeDialog(false);
|
||||
if (!holding) { visible = false; grab = null; sideGrab = null; dismissedAt = now; }
|
||||
}
|
||||
if (trigger && !h.trigger && hover) {
|
||||
pressed = { hand, id: hover };
|
||||
@@ -954,12 +1083,16 @@
|
||||
// being up, counts as using it), each part after its own wait (Settings → Auto-hide,
|
||||
// fade-options.js): everything (the controls panel, the open panels, the lasers), the
|
||||
// side toolbars with their open panels, the search bar. Pointing at anything (where a
|
||||
// faded one is, too) brings them back.
|
||||
// faded one is, too) brings them back. With Settings → Auto-hide → "Auto-hide while paused"
|
||||
// on, a paused video counts too (not with no video at all). After a trigger press on
|
||||
// nothing hid everything, the toolbars and the search bar stay hidden until the next use.
|
||||
const F = ns.vrFadeOptions, unused = now - lastActivity;
|
||||
const idle = !st.paused && !scrub && !setDrag && !pressed && !grab && !menu && !dialog && !canSkip && !typingNow() && !kbShown;
|
||||
if (dismissedAt >= 0 && lastActivity > dismissedAt) dismissedAt = -1;
|
||||
const playing = !st.paused || (F.hideWhilePaused() && st.duration > 0);
|
||||
const idle = playing && !scrub && !setDrag && !pressed && !grab && !sideGrab && !menu && !dialog && !canSkip && !typingNow() && !kbShown;
|
||||
if (visible && idle && unused > F.seconds('panels') * 1000) visible = false;
|
||||
barsAwake = !(idle && unused > F.seconds('bars') * 1000);
|
||||
searchAwake = !(idle && unused > F.seconds('search') * 1000);
|
||||
barsAwake = dismissedAt < 0 && !(idle && unused > F.seconds('bars') * 1000);
|
||||
searchAwake = dismissedAt < 0 && !(idle && unused > F.seconds('search') * 1000);
|
||||
|
||||
// The keyboard: redrawn only when its keys (Shift, page, layout), what's pointed at or
|
||||
// pressed, or what you're typing into change.
|
||||
@@ -974,21 +1107,22 @@
|
||||
}
|
||||
|
||||
// Redraw the canvas only when something visible changed.
|
||||
let hover = null;
|
||||
for (const h of hands.values()) if (h.hover) hover = h.hover;
|
||||
let topModel = null;
|
||||
if (visible) {
|
||||
let hover = null;
|
||||
for (const h of hands.values()) if (h.hover) hover = h.hover;
|
||||
const quality = st.quality || { current: null, levels: [] };
|
||||
const format = st.format || { current: null, options: [] };
|
||||
const currentFormat = format.options.find(o => o.id === format.current);
|
||||
let menuState = null;
|
||||
if (menu === 'quality') {
|
||||
menuState = {
|
||||
items: quality.levels.map(q => ({ id: `q:${q.id}`, text: q.label, on: q.id === quality.current })),
|
||||
items: quality.levels.map(q => ({ id: `q:${q.id}`, text: q.label, on: q.id === quality.current, icons: q.icons, disabled: !!q.disabled })),
|
||||
note: quality.levels.length ? null : tr('panel.noQualities', { site: site.name || tr('panel.theSite') }),
|
||||
};
|
||||
} else if (menu === 'format') {
|
||||
menuState = {
|
||||
items: format.options.map(o => ({ id: `f:${o.id}`, text: o.short, on: o.id === format.current, disabled: o.disabled })),
|
||||
items: format.options.map(o => ({ id: `f:${o.id}`, text: o.short, on: o.id === format.current, disabled: o.disabled, icons: [ns.vrPanelUI.formatIcon(o.id)] })),
|
||||
note: null,
|
||||
};
|
||||
} else if (menu === 'speed') {
|
||||
@@ -998,6 +1132,10 @@
|
||||
const x = extraOf(menu.slice(2)), mm = x && x.menu;
|
||||
if (mm) menuState = { ...mm, key: menu, items: (mm.items || []).map(it => ({ ...it, id: `xi:${x.id}:${it.id}` })) };
|
||||
else menu = null; // the button went away
|
||||
} else if (panelMenu(menu)) { // a side panel's menu (its items' ids: 'pi:<index>')
|
||||
const pm = panelMenu(menu), mm = shown(pm.p) && pm.p.ctrl.menu ? pm.p.ctrl.menu(pm.local) : null;
|
||||
if (mm) menuState = { ...mm, key: menu, items: (mm.items || []).map((it, i) => ({ ...it, id: `pi:${i}` })) };
|
||||
else menu = null; // the panel closed, or has no menu there now
|
||||
}
|
||||
const volume = st.volume || { level: 100, muted: false };
|
||||
muted = !!volume.muted;
|
||||
@@ -1021,7 +1159,7 @@
|
||||
quality, format: { id: format.current, label: currentFormat ? currentFormat.short : '' },
|
||||
speed: st.speed || 1, volume: shownVolume, chapters: st.chapters || [], heatmap: st.heatmap || [], preview, ad,
|
||||
menuOpen: menu, grabbing: !!grab,
|
||||
extras: extrasNow ? { left: (extrasNow.left || []).map(stripMenu), right: (extrasNow.right || []).map(stripMenu) } : null,
|
||||
extras: extrasNow ? { left: (extrasNow.left || []).map(stripMenu), right: (extrasNow.right || []).map(stripMenu), seek: extrasNow.seek || null } : null,
|
||||
};
|
||||
const pf = preview && preview.frame;
|
||||
const key = [state.title, Math.floor(state.time), Math.floor(state.duration), state.paused, state.buffered != null ? Math.floor(state.buffered) : '',
|
||||
@@ -1061,7 +1199,6 @@
|
||||
|
||||
// The top panel's controller is asked every frame (its search bar is always shown):
|
||||
// { model, key } as any panel's, plus bar (the bar's field) and open (show the panel).
|
||||
let topModel = null;
|
||||
if (topPanel) {
|
||||
topModel = topPanel.ctrl.model(frameInfo);
|
||||
topPanel.topOpen = !!topModel.open; topPanel.bar = topModel.bar || null;
|
||||
@@ -1072,31 +1209,33 @@
|
||||
for (const id of panels.keys()) openItems[`toggle:${id}`] = isOpen(id);
|
||||
for (const [name, o] of Object.entries(orbiters)) {
|
||||
const barUi = name === 'bar' ? { hover: localOf(hover, topPanel), pressed: localOf(pressed && pressed.id, topPanel) } : null;
|
||||
const oKey = [name === 'bar' ? JSON.stringify([topPanel.bar, barUi, topPanel.topOpen]) : JSON.stringify(openItems), hover, pressed && pressed.id,
|
||||
const held = sideGrab && sideGrab.side === name ? `sgrab:${name}` : null; // a side's handle while held
|
||||
const oKey = [name === 'bar' ? JSON.stringify([topPanel.bar, barUi, topPanel.topOpen]) : JSON.stringify(openItems), hover, pressed && pressed.id, held,
|
||||
ns.i18n.version(), ns.vrTokens.glass.version, o.ui.moving() ? now : ''].join('|');
|
||||
if (oKey === o.key) continue;
|
||||
if (name === 'pill') o.ui.draw({ open: isOpen(popupPanel.reg.id), hover, pressed: pressed && pressed.id });
|
||||
else if (name === 'bar') o.ui.draw(topPanel.bar || {}, barUi, topPanel.topOpen, now);
|
||||
else o.ui.draw({ open: openItems, hover, pressed: pressed && pressed.id, now });
|
||||
else o.ui.draw({ open: openItems, hover, pressed: pressed && pressed.id, held, now });
|
||||
o.pass.upload();
|
||||
o.key = oKey;
|
||||
}
|
||||
}
|
||||
|
||||
// The open panels: each controller says what to show (and a key that changes whenever
|
||||
// that would look different); redrawn only when the key, the hover / pressed control
|
||||
// or the loaded images change.
|
||||
for (const p of panels.values()) {
|
||||
if (!shown(p)) continue;
|
||||
const { model: pm, key: ck } = p === topPanel ? topModel : p.ctrl.model(frameInfo);
|
||||
const pui = { hover: localOf(hover, p), pressed: localOf(pressed && pressed.id, p) };
|
||||
const pKey = [ck, pui.hover, pui.pressed, p.style.imagesReady(), ns.i18n.version(), ns.vrTokens.glass.version,
|
||||
p.style.animating && p.style.animating() ? now : ''].join('\u0001'); // every frame while a hover / press eases
|
||||
if (pKey !== p.key) {
|
||||
p.style.draw(pm, pui);
|
||||
if (p.ctrl.afterDraw) p.ctrl.afterDraw();
|
||||
try { p.pass.upload(); } catch (err) { console.warn(`[streamxr] ${p.reg.id} panel blocked (images):`, err); }
|
||||
p.key = pKey;
|
||||
}
|
||||
// The open panels (while the panels are up; one that stays, also while they're hidden):
|
||||
// each controller says what to show (and a key that changes whenever that would look
|
||||
// different); redrawn only when the key, the hover / pressed control or the loaded
|
||||
// images change.
|
||||
for (const p of panels.values()) {
|
||||
if (!shown(p)) continue;
|
||||
const { model: pm, key: ck } = p === topPanel ? topModel : p.ctrl.model(frameInfo);
|
||||
const pui = { hover: localOf(hover, p), pressed: localOf(pressed && pressed.id, p) };
|
||||
const pKey = [ck, pui.hover, pui.pressed, p.style.imagesReady(), ns.i18n.version(), ns.vrTokens.glass.version,
|
||||
p.style.animating && p.style.animating() ? now : ''].join('\u0001'); // every frame while a hover / press eases
|
||||
if (pKey !== p.key) {
|
||||
p.style.draw(pm, pui);
|
||||
if (p.ctrl.afterDraw) p.ctrl.afterDraw();
|
||||
try { p.pass.upload(); } catch (err) { console.warn(`[streamxr] ${p.reg.id} panel blocked (images):`, err); }
|
||||
p.key = pKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1147,7 +1286,8 @@
|
||||
function drawPanel(viewerPose, layer, backdrop = null) {
|
||||
if (!fovMeasured) { ns.vrSafeZone.measure(viewerPose); fovMeasured = true; }
|
||||
const V = Math.min(1, Math.max(0, anim.vis.get()));
|
||||
if (V < 0.01) return;
|
||||
const staying = [...panels.values()].some(p => p.reg.stays && isOpen(p.reg.id)); // (drawn while the rest is hidden)
|
||||
if (V < 0.01 && !staying) return;
|
||||
const K = Math.min(1, Math.max(0, anim.kb.get())), vRaw = anim.vis.get(), kRaw = anim.kb.get();
|
||||
gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL);
|
||||
gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
@@ -1158,12 +1298,13 @@
|
||||
const cpA = V * (1 - K), kbA = V * K, C = [0, 0];
|
||||
if (cpA > 0.01) items.push([panelPass, scaled(model, C, shrink(vRaw) * shrink(1 - kRaw)), size, backdrop, cpA]);
|
||||
if (kbA > 0.01) items.push([kbPass, scaled(kbModel(), C, shrink(vRaw) * shrink(kRaw)), kbSize(), backdrop, kbA]);
|
||||
// (a side panel also fades with its side's toolbar: anim.bars, the side toolbars' own wait)
|
||||
// (a side panel also fades with its side's toolbar: anim.bars, the side toolbars' own
|
||||
// wait; one that stays fades with neither, only opening and closing)
|
||||
const B = Math.min(1, Math.max(0, anim.bars.get()));
|
||||
for (const p of panels.values()) {
|
||||
const a = anim.open.get(p.reg.id), o = a ? a.get() : 0;
|
||||
const oa = Math.min(1, Math.max(0, o)) * V * (p.zone === 'left' || p.zone === 'right' ? B : 1);
|
||||
if (oa > 0.01 && (p.zone === 'controls' || p.pose)) items.push([p.pass, scaled(panelModel(p), pivotOf(p), shrink(o) * shrink(vRaw)), p.size, backdrop, oa]);
|
||||
const a = anim.open.get(p.reg.id), o = a ? a.get() : 0, stays = !!p.reg.stays;
|
||||
const oa = Math.min(1, Math.max(0, o)) * (stays ? 1 : V * (p.zone === 'left' || p.zone === 'right' ? B : 1));
|
||||
if (oa > 0.01 && (p.zone === 'controls' || p.pose)) items.push([p.pass, scaled(panelModel(p), pivotOf(p), shrink(o) * (stays ? 1 : shrink(vRaw))), p.size, backdrop, oa]);
|
||||
}
|
||||
for (const [name, o] of Object.entries(orbiters)) {
|
||||
// the pill goes with the controls panel; the side toolbars and the search bar also
|
||||
@@ -1173,7 +1314,9 @@
|
||||
}
|
||||
if (hoverShown && !kbShown && cpA > 0.01) items.push([hoverPass, hoverCardModel(hoverShown.rect), hoverSize, null, cpA]);
|
||||
// The floating menu, growing up from its button (its canvas's bottom edge).
|
||||
const MA = Math.min(1, Math.max(0, anim.menu.get())) * cpA, mm = MA > 0.01 && menuShown ? menuModel(menuShown) : null;
|
||||
// (A side panel's menu fades with its panel's side instead of the controls panel.)
|
||||
const menuA = panelMenu(menuShown) ? (panelMenu(menuShown).p.reg.stays ? 1 : V * B) : cpA;
|
||||
const MA = Math.min(1, Math.max(0, anim.menu.get())) * menuA, mm = MA > 0.01 && menuShown ? menuModel(menuShown) : null;
|
||||
if (mm) items.push([menuPass, scaled(mm, [0, -menuSize[1] / 2], shrink(anim.menu.get())), menuSize, backdrop, MA]);
|
||||
// Settled ones first (writing depth), then the ones still fading.
|
||||
items.sort((a, b) => (b[4] > 0.99) - (a[4] > 0.99));
|
||||
|
||||
@@ -88,6 +88,8 @@
|
||||
'fade.lasersHint': 'When the controller is held still',
|
||||
'fade.seconds': '{n} s',
|
||||
'fade.never': 'Never',
|
||||
'fade.paused': 'Auto-hide while paused',
|
||||
'fade.pausedHint': 'The waits above run with the video paused too',
|
||||
'safezone.note': 'Where the panels go: a share of what your headset shows ({h}° × {v}°). The outline shows it.',
|
||||
'safezone.width': 'Width',
|
||||
'safezone.widthHint': 'Of your view, left to right',
|
||||
@@ -101,6 +103,8 @@
|
||||
'safezone.taller': 'Taller',
|
||||
'safezone.lower': 'Lower',
|
||||
'safezone.higher': 'Higher',
|
||||
'safezone.recenter': 'Recenter resets panels',
|
||||
'safezone.recenterHint': 'Recentering also puts the panels you moved back in their places',
|
||||
'keyboard.note': 'The VR keyboard (A button). Default: your computer’s keyboard and language.',
|
||||
'keyboard.style': 'Style',
|
||||
'keyboard.language': 'Language',
|
||||
|
||||
@@ -80,6 +80,8 @@
|
||||
'fade.lasersHint': 'Al dejar el mando quieto',
|
||||
'fade.seconds': '{n} s',
|
||||
'fade.never': 'Nunca',
|
||||
'fade.paused': 'Ocultar automáticamente en pausa',
|
||||
'fade.pausedHint': 'Las esperas de arriba cuentan también con el vídeo en pausa',
|
||||
'safezone.note': 'Dónde van los paneles: una parte de lo que muestran tus gafas ({h}° × {v}°). El contorno la muestra.',
|
||||
'safezone.width': 'Ancho',
|
||||
'safezone.widthHint': 'De tu vista, de izquierda a derecha',
|
||||
@@ -93,6 +95,8 @@
|
||||
'safezone.taller': 'Más alta',
|
||||
'safezone.lower': 'Más abajo',
|
||||
'safezone.higher': 'Más arriba',
|
||||
'safezone.recenter': 'Recentrar restablece los paneles',
|
||||
'safezone.recenterHint': 'Al recentrar, los paneles que moviste también vuelven a su sitio',
|
||||
'keyboard.note': 'El teclado de VR (botón A). Predeterminado: el teclado y el idioma de tu ordenador.',
|
||||
'keyboard.style': 'Distribución',
|
||||
'keyboard.language': 'Idioma',
|
||||
|
||||
@@ -89,6 +89,8 @@
|
||||
'fade.lasersHint': 'When the controller is held still',
|
||||
'fade.seconds': '{n} s',
|
||||
'fade.never': 'Never',
|
||||
'fade.paused': 'Auto-hide while paused',
|
||||
'fade.pausedHint': 'The waits above run with the video paused too',
|
||||
'safezone.note': 'Where the panels go: a share of what your headset shows ({h}° × {v}°). The outline shows it.',
|
||||
'safezone.width': 'Width',
|
||||
'safezone.widthHint': 'Of your view, left to right',
|
||||
@@ -102,6 +104,8 @@
|
||||
'safezone.taller': 'Taller',
|
||||
'safezone.lower': 'Lower',
|
||||
'safezone.higher': 'Higher',
|
||||
'safezone.recenter': 'Recenter resets panels',
|
||||
'safezone.recenterHint': 'Recentering also puts the panels you moved back in their places',
|
||||
'keyboard.note': 'The VR keyboard (A button). Default: your computer’s keyboard and language.',
|
||||
'keyboard.style': 'Style',
|
||||
'keyboard.language': 'Language',
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
'fade.lasersHint': 'Al dejar el mando quieto',
|
||||
'fade.seconds': '{n} s',
|
||||
'fade.never': 'Nunca',
|
||||
'fade.paused': 'Ocultar automáticamente en pausa',
|
||||
'fade.pausedHint': 'Las esperas de arriba cuentan también con el vídeo en pausa',
|
||||
'safezone.note': 'Dónde van los paneles: una parte de lo que muestran tus gafas ({h}° × {v}°). El contorno la muestra.',
|
||||
'safezone.width': 'Ancho',
|
||||
'safezone.widthHint': 'De tu vista, de izquierda a derecha',
|
||||
@@ -94,6 +96,8 @@
|
||||
'safezone.taller': 'Más alta',
|
||||
'safezone.lower': 'Más abajo',
|
||||
'safezone.higher': 'Más arriba',
|
||||
'safezone.recenter': 'Recentrar restablece los paneles',
|
||||
'safezone.recenterHint': 'Al recentrar, los paneles que moviste también vuelven a su sitio',
|
||||
'keyboard.note': 'El teclado de VR (botón A). Predeterminado: el teclado y el idioma de tu ordenador.',
|
||||
'keyboard.style': 'Distribución',
|
||||
'keyboard.language': 'Idioma',
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// YouTube's feed panels, both in the Catalog style: Explore (YouTube's Home feed with its
|
||||
// topic chips, then the Explore pages of YouTube's left menu) and You (your own feeds, signed
|
||||
// in: Subscriptions, Your channel, History, Playlists, Watch later, Liked videos, Movies &
|
||||
// in: Subscriptions, Channels (the ones you subscribe to: a click opens one in the Channel
|
||||
// panel), Your channel, History, Playlists, Watch later, Liked videos, Movies &
|
||||
// TV). Each shows its sources as tabs (the row slides when they don't all fit), the loaded
|
||||
// videos (a pager that loads more pages as you page through) and the current page. A source
|
||||
// that isn't built yet shows "Coming soon" with the construction icon. A source loads the
|
||||
// first time it shows. Panel controllers: see panels.js.
|
||||
// actions used: browseHome(chip) -> pager (youtube-home.js home(); p.chips after a page),
|
||||
// browseSubscriptions() -> pager (youtube-subscriptions.js feed()), browseFeed(name) ->
|
||||
// browseSubscriptions() -> pager (youtube-subscriptions.js feed()), browseChannels() ->
|
||||
// pager (youtube-subscriptions.js channels()), browseFeed(name) ->
|
||||
// pager (youtube-home.js feedVideos()), browsePlaylist(id) -> pager (youtube-content.js
|
||||
// playlistVideos()), myChannelId() -> Promise of id | null (youtube-home.js),
|
||||
// channelHeader(id) -> Promise of header (its tabs), channelList(id, tab) -> pager
|
||||
@@ -21,8 +23,11 @@
|
||||
// in the string tables, and with `note` the grey line under the tabs 'yt.browse.<id>Note'),
|
||||
// icon (Material Symbols, symbols.js), and either load(actions, chip) (makes the pager;
|
||||
// `list` is the playlist its videos play in) with optional chips, or soon (not built yet:
|
||||
// "Coming soon"). chips: 'home' (the Home feed's topic chips, from its pager), or a fixed
|
||||
// list of chip ids (named 'yt.channel.<id>'), the first one chosen at first.
|
||||
// "Coming soon"). card (optional): how an item becomes a card (default: a video card);
|
||||
// pick(host, item) (optional): what clicking one does (default: play it). chips: 'home'
|
||||
// (the Home feed's topic chips, from its pager), 'sorts' (the sort choices its pager
|
||||
// offers, p.sorts: [{ text, selected }]), or a fixed list of chip ids (named
|
||||
// 'yt.channel.<id>'), the first one chosen at first.
|
||||
const EXPLORE = [
|
||||
{ id: 'home', icon: 'home', load: (a, chip) => a.browseHome(chip), chips: 'home' },
|
||||
{ id: 'shorts', icon: 'smart_display', soon: true },
|
||||
@@ -37,9 +42,28 @@
|
||||
{ id: 'podcasts', icon: 'podcasts', soon: true },
|
||||
{ id: 'memberships', icon: 'card_membership', soon: true },
|
||||
];
|
||||
// A subscribed channel (youtube-subscriptions.js channels()) as a card: its round picture,
|
||||
// name, handle · subscribers, and the bell's setting when YouTube offers one; a flag with
|
||||
// its section's title when it's listed apart (e.g. "Purchased": a channel you're a member of).
|
||||
function channelCard(c) {
|
||||
const n = c.notifications || {}, cur = String(n.current || '').toLowerCase();
|
||||
const now = (n.options || []).find(o => o.selected) || (n.options || []).find(o => cur && cur.includes(String(o.label).toLowerCase()));
|
||||
const bell = now ? now.label : '';
|
||||
return { title: c.title, thumb: c.thumb, fit: 'round', flags: c.aside ? [{ text: c.aside, bg: 'tertiary', fg: 'surface' }] : [],
|
||||
lines: [{ text: [c.handle, c.subscribers].filter(Boolean).join(' · '), badge: c.badge || null },
|
||||
...(bell ? [{ text: tr('yt.bell.line', { name: bell }) }] : [])] };
|
||||
}
|
||||
|
||||
// Clicking a channel: show it in the Channel panel (left of the zone).
|
||||
function showChannel(host, c) {
|
||||
const ch = host.panel && host.panel('channel');
|
||||
if (ch && ch.show) ch.show(c.id, { subscribed: true, notifications: c.notifications });
|
||||
}
|
||||
|
||||
const MY_TABS = ['videos', 'shorts', 'live', 'playlists']; // Your channel's chips (those it has)
|
||||
const YOU = [
|
||||
{ id: 'subs', icon: 'subscriptions', load: a => a.browseSubscriptions(), note: true },
|
||||
{ id: 'channels', icon: 'person', load: (a, sort) => a.browseChannels(sort), chips: 'sorts', note: true, card: channelCard, pick: showChannel },
|
||||
{ id: 'mychannel', icon: 'account_box', load: (a, chip) => myChannel(a, chip, MY_TABS), chips: MY_TABS },
|
||||
{ id: 'history', icon: 'history', load: a => a.browseFeed('history'), note: true },
|
||||
{ id: 'playlists', icon: 'playlist_play', load: a => a.browseFeed('playlists'), note: true },
|
||||
@@ -82,6 +106,7 @@
|
||||
const perPage = host.perPage;
|
||||
let source = sources[0].id;
|
||||
let homeChips = []; // the Home feed's chip texts on offer, in display order
|
||||
const sortsOf = {}; // source id -> the sort choices its pager offers (chips: 'sorts')
|
||||
const chipOf = { home: 'All' }; // source id -> its chosen chip id
|
||||
const availOf = {}; // source id -> the fixed chips it turned out to have (p.tabs)
|
||||
let pager = null, page = 0, loading = false, error = '', ver = 0;
|
||||
@@ -94,6 +119,7 @@
|
||||
// A source's chips ([{ id, label }]) and its chosen one.
|
||||
function chipList(s = current()) {
|
||||
if (s.chips === 'home') return homeChips.map(t => ({ id: t, label: t }));
|
||||
if (s.chips === 'sorts') return (sortsOf[s.id] || []).map(t => ({ id: t, label: t }));
|
||||
return (availOf[s.id] || s.chips || []).map(id => ({ id, label: tr(`yt.channel.${id}`) }));
|
||||
}
|
||||
const chipNow = (s = current()) => chipOf[s.id] || (Array.isArray(s.chips) ? s.chips[0] : null);
|
||||
@@ -113,6 +139,12 @@
|
||||
loading = true; bump();
|
||||
p.next().then(() => {
|
||||
if (p === pager && current().chips === 'home' && p.chips && p.chips.length) homeChips = orderChips(p.chips);
|
||||
// A list with its own sort choices (the Channels list): offered as its pills, the one
|
||||
// it's sorted by chosen.
|
||||
if (p === pager && current().chips === 'sorts' && p.sorts && p.sorts.length) {
|
||||
sortsOf[source] = p.sorts.map(x => x.text);
|
||||
const on = p.sorts.find(x => x.selected); if (on) chipOf[source] = on.text;
|
||||
}
|
||||
// A list that found out which of its chips exist (Your channel's tabs), and which it read.
|
||||
if (p === pager && p.tabs && p.tabs.length) { availOf[source] = p.tabs; if (p.tab) chipOf[source] = p.tab; }
|
||||
}, err => { if (p === pager) error = tr('yt.browse.failed', { error: err && err.message || err }); })
|
||||
@@ -141,7 +173,8 @@
|
||||
page = ev.value === 'next' ? Math.min(pageCount() - 1, page + 1) : Math.max(0, page - 1); bump();
|
||||
} else if (ev.type === 'item') {
|
||||
const it = items()[page * perPage + +ev.value];
|
||||
if (it) actions.openVideo(it.id, it.playlistId || current().list);
|
||||
if (it && current().pick) current().pick(host, it);
|
||||
else if (it) actions.openVideo(it.id, it.playlistId || current().list);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +190,7 @@
|
||||
title: tr(title), tabs: tabs(), tab: source,
|
||||
pills: chips, pill: chips.length ? chipNow(s) : null,
|
||||
note: s.note ? tr(`yt.browse.${source}Note`) : '',
|
||||
items: items().slice(page * perPage, page * perPage + perPage).map(it => ns.ytVr.card(it)),
|
||||
items: items().slice(page * perPage, page * perPage + perPage).map(it => (s.card ? s.card(it) : ns.ytVr.card(it))),
|
||||
page, pageCount: pageCount(), more: !!pager && !pager.done, loading,
|
||||
emptyText: s.soon ? tr('yt.comingSoon') : loading ? tr('yt.loading') : message || tr('yt.nothingYet'),
|
||||
emptyIcon: s.soon ? 'construction' : null,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// YouTube's "Channel" panel (Profile style): which channel is open, its header, the chosen
|
||||
// tab and sort, the loaded videos (a pager that loads more pages as you page through), the
|
||||
// current page, the channel's featured video (a "Featured" tab, first, when the channel
|
||||
// has one), and the Subscribe button (unsubscribing asks in a dialog). Three tabs aren't
|
||||
// has one), and the Subscribe button (unsubscribing asks in a dialog), with the notification
|
||||
// bell beside it once subscribed (a round button opening a menu of YouTube's choices). Three tabs aren't
|
||||
// video grids: Store (product cards, just to look at: buying leads off YouTube), Posts (a
|
||||
// scrolling feed, more loading near its end) and About (a scrolling page). On a post you
|
||||
// can like / dislike it and vote in its poll (signed in; shown at once, undone if YouTube
|
||||
@@ -16,12 +17,16 @@
|
||||
// openCurrent() show the playing video's channel
|
||||
// showCurrent() the channel name was clicked: fold the panel if it
|
||||
// shows that channel, else open it on it
|
||||
// show(id, hint) show a channel picked elsewhere (the You panel's
|
||||
// Channels list; hint: { subscribed, notifications })
|
||||
// and open the panel
|
||||
// noteSubscribed(id, on) a subscription changed elsewhere (controls panel)
|
||||
// channelId() the channel shown (or null)
|
||||
// actions used: channelHeader(id) -> Promise of header (youtube-channel.js channel()),
|
||||
// channelList(id, tab, sort) -> pager (Store: Promise of [products]), channelAbout(id) ->
|
||||
// Promise of about | null, channelFeatured(id) -> Promise of item | null,
|
||||
// setSubscribed(id, on) -> Promise of { ok, error }, openVideo(id, playlistId), state(),
|
||||
// setSubscribed(id, on) -> Promise of { ok, error }, setBell(option) -> Promise of { ok, error },
|
||||
// bellMatches(label, state), openVideo(id, playlistId), state(),
|
||||
// ratePost(post, what), votePoll(post, index, on) -> Promise of { ok, error },
|
||||
// postComments(post, sort) -> pager, replies(comment), rateComment(comment, what).
|
||||
(function (ns) {
|
||||
@@ -73,6 +78,7 @@
|
||||
const rated = new Map(); // post id -> 'liked' | 'disliked' | 'none' (your clicks)
|
||||
const voted = new Map(); // post id -> the poll choice you picked (-1: none) (your clicks)
|
||||
let sub = 'unknown'; // 'unknown' | 'subscribed' | 'unsubscribed' | 'busy'
|
||||
let bell = null; // the channel's bell: { state, options: [{ label, params, selected }], busy } | null
|
||||
let gen = 0, ver = 0; // gen: ignore answers for a channel/list no longer shown
|
||||
const bump = () => { ver++; };
|
||||
|
||||
@@ -225,18 +231,22 @@
|
||||
else if (k.length === 1) { search.text += k; bump(); }
|
||||
}
|
||||
|
||||
function openChannel(id, subscribedHint) {
|
||||
// bellHint: the channel's bell from elsewhere (the Channels list's notifications), until
|
||||
// the header brings its own.
|
||||
function openChannel(id, subscribedHint, bellHint) {
|
||||
if (!id || id === channelId) return;
|
||||
if (search) { searchFocus(false); search = null; }
|
||||
channelId = id; header = null; error = ''; pager = null; page = 0; loading = false; featured = null; about = undefined; scroll = 0; opened = null;
|
||||
tab = 'videos'; sort = SORTS.videos[0].id;
|
||||
sub = subscribedHint == null ? 'unknown' : subscribedHint ? 'subscribed' : 'unsubscribed';
|
||||
bell = bellOf(bellHint);
|
||||
const my = ++gen;
|
||||
bump();
|
||||
Promise.resolve(actions.channelHeader(id)).then(h => {
|
||||
if (my !== gen) return;
|
||||
header = h;
|
||||
if (h && h.subscribed != null && sub === 'unknown') sub = h.subscribed ? 'subscribed' : 'unsubscribed';
|
||||
if (!bell && h) bell = bellOf(h.notifications);
|
||||
const lists = tabs().filter(t => t.id !== 'featured');
|
||||
if (!lists.some(t => t.id === tab) && lists.length) { tab = lists[0].id; sort = firstSort(tab); }
|
||||
startList();
|
||||
@@ -271,8 +281,43 @@
|
||||
}, err => { if (id === channelId) { sub = before; console.warn('[streamxr] subscription change failed:', err); bump(); } });
|
||||
}
|
||||
|
||||
// ---- the notification bell (signed in and subscribed, when YouTube offers one)
|
||||
// The bell from a header's or the Channels list's `notifications` ({ state | current,
|
||||
// options }), with the current choice marked (selected; else the one the state or the
|
||||
// current label names), or null when it has no choices.
|
||||
function bellOf(n) {
|
||||
if (!n || !n.options || !n.options.length) return null;
|
||||
const options = n.options.map(o => ({ ...o }));
|
||||
if (!options.some(o => o.selected)) {
|
||||
const now = options.find(o => n.state && actions.bellMatches && actions.bellMatches(o.label, n.state))
|
||||
|| options.find(o => n.current && String(n.current).toLowerCase().includes(String(o.label).toLowerCase()));
|
||||
if (now) now.selected = true;
|
||||
}
|
||||
return { options, busy: false };
|
||||
}
|
||||
// A choice's icon (Material Symbols) from its label (YouTube's words: All / Personalized / None).
|
||||
const bellIcon = label => (/^all/i.test(label) ? 'notifications_active' : /none|off/i.test(label) ? 'notifications_off' : 'notifications');
|
||||
// The bell's menu (host.openMenu('action:bell')): YouTube's choices, the current one ticked.
|
||||
function menu(local) {
|
||||
if (local !== 'action:bell' || !bell) return null;
|
||||
return { note: tr('yt.bell.title'), items: bell.options.map(o => ({ id: o.params, text: o.label, on: !!o.selected, icons: [bellIcon(o.label)], disabled: bell.busy })) };
|
||||
}
|
||||
// Choose a bell setting; the tick moves only when YouTube's answer confirms it (or, with no
|
||||
// state in the answer, when it says OK).
|
||||
function chooseBell(params) {
|
||||
const b = bell, o = b && b.options.find(x => x.params === params), id = channelId;
|
||||
if (!o || o.selected || b.busy) return;
|
||||
b.busy = true; bump();
|
||||
Promise.resolve(actions.setBell(o)).then(res => {
|
||||
if (res && res.ok) b.options.forEach(x => { x.selected = x === o; });
|
||||
else console.warn('[streamxr] bell change failed:', res && res.error);
|
||||
}, err => console.warn('[streamxr] bell change failed:', err)).finally(() => { b.busy = false; if (id === channelId) bump(); });
|
||||
}
|
||||
|
||||
function event(ev) {
|
||||
if (!channelId) return;
|
||||
if (ev.type === 'action' && ev.value === 'bell') { if (bell) host.openMenu('action:bell'); return; }
|
||||
if (ev.type === 'menu' && ev.menu === 'action:bell') { chooseBell(ev.value); return; }
|
||||
// Searching: the search bar's controls and the typed keys.
|
||||
if (ev.type === 'search') { startSearch(); return; }
|
||||
if (search && !opened) {
|
||||
@@ -346,6 +391,9 @@
|
||||
stats: [h.handle, h.subscribers, h.videoCount].filter(Boolean).join(' · ') } : null,
|
||||
loadingTitle: tr('yt.channel.loading'),
|
||||
action: channelId ? { id: 'subscribe', look: BUTTON[sub].look, label: BUTTON[sub].key ? tr(BUTTON[sub].key) : '…' } : null,
|
||||
// The bell, beside Subscribed: its icon shows the current choice.
|
||||
iconAction: channelId && sub === 'subscribed' && bell ? { id: 'bell', look: bell.busy ? 'busy' : 'secondary',
|
||||
icon: bellIcon((bell.options.find(o => o.selected) || { label: '' }).label) } : null,
|
||||
tabs: tabs(), tab, pills: search ? [] : named(SORTS[tab] || []), pill: sort,
|
||||
search: !search && !!(header && header.tabs && header.tabs.search), // the channel can be searched
|
||||
field: search ? { id: 'query', text: search.text, placeholder: tr('yt.channel.searchIn', { name: (header && header.title) || tr('yt.channel.thisChannel') }),
|
||||
@@ -381,7 +429,20 @@
|
||||
host.open();
|
||||
}
|
||||
|
||||
return { model, event, afterDraw, opened: openCurrent, openChannel, openCurrent, showCurrent, noteSubscribed, channelId: () => channelId };
|
||||
// A channel picked elsewhere (the You panel's Channels list): show it and open the panel.
|
||||
// hint: { subscribed, notifications } from that list, tab (optional): the tab to open on
|
||||
// (e.g. 'posts'; the first one it has if it lacks that).
|
||||
function show(id, hint = {}) {
|
||||
if (id !== channelId) openChannel(id, hint.subscribed, hint.notifications);
|
||||
if (hint.tab && SORTS[hint.tab] && hint.tab !== tab) {
|
||||
closePost();
|
||||
tab = hint.tab; sort = firstSort(tab);
|
||||
if (header) startList(); else bump(); // (before the header arrives, its callback starts the list)
|
||||
}
|
||||
if (!host.isOpen()) host.open();
|
||||
}
|
||||
|
||||
return { model, event, menu, afterDraw, opened: openCurrent, openChannel, openCurrent, showCurrent, show, noteSubscribed, channelId: () => channelId };
|
||||
}
|
||||
|
||||
ns.ytVr = ns.ytVr || {};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// YouTube's own buttons on the controls panel (the shell's "extras", vr-panel.js): Captions
|
||||
// (left: a menu of Off and the video's caption tracks; the chosen track's lines are drawn in
|
||||
// VR by the shell, captions-overlay.js) and Save (right: a scrolling menu of Watch later and
|
||||
// VR by the shell, captions-overlay.js), Save (right: a scrolling menu of Watch later and
|
||||
// your playlists, each ticked when the video is in it; a choice adds it or takes it out, and
|
||||
// the menu stays open).
|
||||
// the menu stays open), and Jump ahead (at the seek bar's end: YouTube Premium's jump to where
|
||||
// most viewers skip to, marked with the Diamond).
|
||||
//
|
||||
// const ex = ns.ytVr.extras.create(); one per VR session (provider.js session())
|
||||
// ex.state(st) -> { left: [captions], right: [save] } every frame (st: { ad, noVideo }: both
|
||||
// ex.state(st) -> { left: [captions], right: [save], seek: jump | null } every frame (st: { ad, noVideo, paused, live }: all
|
||||
// are greyed out during an ad or with no video playing; the provider's
|
||||
// state); the buttons as panel-ui.js / vr-panel.js read them
|
||||
// ex.extra(id, itemId) the shell's actions.extra: a button's menu opened (itemId null) or
|
||||
@@ -72,10 +73,17 @@
|
||||
menu: { items: list.map(o => ({ id: o.id, text: o.busy ? `${o.title}\n${tr('yt.save.busy')}` : o.title, on: o.saved, disabled: o.busy })),
|
||||
note: save.loading && !list.length ? tr('yt.loading') : save.note || null, scroll: true, width: 520, stay: true },
|
||||
};
|
||||
return { left: [cc], right: [sv] };
|
||||
// Jump ahead (YouTube Premium), at the seek bar's end (where Skip ad goes during ads),
|
||||
// only while YouTube's own Jump ahead pill is on screen: the Diamond, its name,
|
||||
// fast-forward; greyed out while paused.
|
||||
const live = !!(st && st.live), none = !!(st && st.noVideo);
|
||||
const ja = live || none || !youtube.jumpAheadOffered() ? null : { id: 'jump', icon: 'fast_forward', label: tr('yt.jumpAhead'), mark: premiumMark(),
|
||||
disabled: !!(st && (st.paused || st.ad)) };
|
||||
return { left: [cc], right: [sv], seek: ja };
|
||||
}
|
||||
|
||||
function extra(id, itemId) {
|
||||
if (id === 'jump') { youtube.jumpAhead(); return; }
|
||||
if (id === 'captions' && itemId != null) {
|
||||
const c = captions();
|
||||
youtube.setCaptions(itemId === 'off' ? null : c.tracks[+itemId] || null);
|
||||
@@ -92,5 +100,11 @@
|
||||
}
|
||||
|
||||
ns.ytVr = ns.ytVr || {};
|
||||
// YouTube Premium's mark: Material Symbols' Diamond, YouTube red when you have Premium, purple
|
||||
// (an upsell, like YouTube's own) when you don't. An icon spec for the shell (symbols.js drawIcon).
|
||||
const PREMIUM_RED = '#ff0033', UPSELL_PURPLE = '#a142f4';
|
||||
function premiumMark() { return { name: 'diamond', color: youtube.hasPremium() ? PREMIUM_RED : UPSELL_PURPLE, filled: true }; }
|
||||
|
||||
ns.ytVr.extras = { create };
|
||||
ns.ytVr.premiumMark = premiumMark;
|
||||
})(globalThis.__streamxr = globalThis.__streamxr || {});
|
||||
@@ -0,0 +1,370 @@
|
||||
// YouTube's "Live chat" panel (Info style, 'chat' content): a live stream's chat, in the
|
||||
// right zone, shown only while one is playing (its registration's `available`) and staying up
|
||||
// while the rest of the panels fade (`stays`). Its tabs are YouTube's chat views (Top chat,
|
||||
// Live chat; the one chosen is remembered). While the panel is open the chat is polled every
|
||||
// POLL_MS (youtube-chat.js; YouTube's own frame is pushed new messages, so its suggested 10 s
|
||||
// wait would lag); closed, it rests, and reopening after a while starts it afresh. The list
|
||||
// follows the newest message; scrolling back pauses it (new messages are counted, not
|
||||
// shown) until you scroll to the end or press "jump to newest". The stream's reaction buttons
|
||||
// (❤ 😄 🎉, from the chat page) sit along the panel's bottom: a click sends one (at most one
|
||||
// every REACT_GAP_MS) and lights the button a moment. Above them, the message field: a click
|
||||
// brings up the VR keyboard (with the channel's own emojis first on its emoji pages: typed as
|
||||
// their shortcuts, sent as emojis), Enter or Send posts the message, Escape (or a click on
|
||||
// nothing) stops typing; where you can't chat, YouTube's reason shows instead. Support opens a
|
||||
// dialog: paying (Super Chat, gifts, memberships) happens in YouTube's own window on the
|
||||
// desktop. Top fans: your XP in a 👑 pill at the right of the tabs (kept up to date by the
|
||||
// polls); it opens the ranked list in the panel (refreshed every FANS_REFRESH_MS while open; a
|
||||
// back button returns to the chat), and the top three have their "#1" pills in the chat.
|
||||
// A finished stream's chat replay plays along with the video instead: its recorded messages
|
||||
// are fetched ahead (REPLAY_AHEAD_MS) and each shows when the video reaches its time, so a
|
||||
// pause stops the chat; a jump back, or ahead past SEEK_AHEAD_MS, starts it again at the new
|
||||
// place (with the last REPLAY_BACKFILL_MS of chat at once). No typing, reactions or Top fans
|
||||
// there, as on YouTube.
|
||||
// A panel controller: see panels.js.
|
||||
// frame.st used: liveChat (youtubeChat.info(): { replay, views } | null), time (a replay's place).
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
const POLL_MS = 2000; // how often to ask for new messages
|
||||
const RETRY_MS = 5000; // after a failed request
|
||||
const MAX_TRIES = 3; // failed requests in a row before giving up (a tab click retries)
|
||||
const RESTART_MS = 30000; // closed longer than this: start afresh when reopened
|
||||
const START_TRIES = 5, START_RETRY_MS = 3000; // a chat that won't load yet (e.g. YouTube's chat frame still
|
||||
// loading for a new video): tried again this often, the spinner showing
|
||||
const MAX_ITEMS = 150; // messages kept while following (more while paused, up to MAX_PAUSED)
|
||||
const MAX_PAUSED = 600;
|
||||
const REACT_GAP_MS = 500; // the shortest time between two reactions sent
|
||||
const REACT_LIT_MS = 700; // how long a sent reaction's button stays lit
|
||||
const FANS_REFRESH_MS = 30000; // how often the open Top fans list is loaded again
|
||||
const REPLAY_AHEAD_MS = 30000; // a replay: fetch more while less than this is waiting ahead of the video
|
||||
const REPLAY_BACKFILL_MS = 60000; // a replay: on starting or a jump, the chat from this long before shows at once
|
||||
const SEEK_BACK_MS = 1500, SEEK_AHEAD_MS = 5000; // the video jumping back / ahead more than this is a seek
|
||||
const VIEW_KEY = 'chatView'; // storage.js name: the chosen view's position (0: Top chat, 1: Live chat)
|
||||
const tr = (key, vars) => ns.i18n.t(key, vars); // words in the interface language (strings-en.js)
|
||||
|
||||
// A chat message (youtube-chat.js) -> the shell's chat item (chat.js), in YouTube's colours
|
||||
// (the theme's custom roles: provider.js): the owner's name on a yellow chip, moderators in
|
||||
// blue with a wrench, members in green with their badge; Super Chats / Stickers as cards in
|
||||
// their tier's colours, memberships as green cards.
|
||||
function itemOf(msg) {
|
||||
const badges = [];
|
||||
if (msg.badge) badges.push({ image: msg.badge });
|
||||
if (msg.role === 'moderator') badges.push({ icon: 'construction', color: 'chatModerator' });
|
||||
if (msg.verified) badges.push({ icon: 'verified', color: 'onSurfaceVariant' });
|
||||
const it = { id: msg.id, author: msg.author, avatar: msg.avatar, channelId: msg.channelId, badges, rank: msg.rank || null,
|
||||
runs: msg.runs.map(r => (r.emoji ? { image: r.emoji.url, label: r.emoji.label } : { text: r.text })) };
|
||||
if (msg.role === 'owner') { it.nameFill = 'chatOwner'; it.nameColor = 'onChatOwner'; }
|
||||
else if (msg.role === 'moderator') it.nameColor = 'chatModerator';
|
||||
else if (msg.role === 'member') it.nameColor = 'chatMember';
|
||||
if (msg.kind === 'notice') return { ...it, kind: 'notice' };
|
||||
if (msg.kind === 'paid' || msg.kind === 'sticker') {
|
||||
return { ...it, kind: 'card', card: { color: msg.colors.header || 'chatMember', onColor: msg.colors.text || 'onSurface', title: msg.amount, sticker: msg.sticker || '' } };
|
||||
}
|
||||
if (msg.kind === 'member') {
|
||||
return { ...it, kind: 'card', card: { color: 'chatMember', onColor: 'onChatMember', title: msg.header || tr('yt.chat.newMember') } };
|
||||
}
|
||||
return { ...it, kind: 'text' };
|
||||
}
|
||||
|
||||
function create(host) {
|
||||
let view = null; // the chosen view's position in st.liveChat.views
|
||||
{ const v = parseInt(ns.storage.get(VIEW_KEY), 10); if (v >= 0) view = v; }
|
||||
// The chat being followed: { video, view (its position), replay, items, ids, banner, next,
|
||||
// status ('loading' | 'live' | 'ended' | 'error'), busy (a request out), due (ms: next
|
||||
// poll), tries, version (changes with what's shown), and youtube-chat.js open()'s reactions,
|
||||
// input, emojis, support; a replay also: first (its start: the token a seek asks with),
|
||||
// queue (fetched changes waiting for their time, `at` ms), queueEnd (the last `at` fetched),
|
||||
// needSeek (fetch from the video's place with `first`), lastT (the video's last place, ms) }
|
||||
let chat = null, lastOpen = 0;
|
||||
let lastReact = 0, lit = {}; // when the last reaction was sent; when each button was (ms)
|
||||
let draft = '', typing = false, sending = false, sendError = ''; // the message being typed
|
||||
// The Top fans list while it's open: { rows, you, loading, error, busy, at (ms: last
|
||||
// loaded), version }, and how far it's scrolled.
|
||||
let fans = null, fansScroll = 0;
|
||||
let follow = true, frozen = null, unseen = 0, scroll = 0;
|
||||
|
||||
const viewsOf = st => (st.liveChat && st.liveChat.views) || [];
|
||||
// The view to show: the one chosen, else the one YouTube selects.
|
||||
function chosen(views) {
|
||||
if (view != null && views[view]) return view;
|
||||
const i = views.findIndex(v => v.selected);
|
||||
return i >= 0 ? i : 0;
|
||||
}
|
||||
|
||||
// Start following view `index` (titled `title`; a replay's or not): find the token that
|
||||
// loads it (youtube-chat.js views(): the same title, else the same position), then load it.
|
||||
// (attempt: how many starts failed before this one: see START_TRIES)
|
||||
function start(index, title, replay, attempt = 0) {
|
||||
const c = chat = { video: ns.youtubeData.currentVideoId(), view: index, title, attempt, retryAt: 0, replay: !!replay, items: [], ids: new Set(), banner: null, next: null,
|
||||
status: 'loading', busy: true, due: 0, tries: 0, version: 0, reactions: [], input: null, emojis: [], support: [], leaderboard: null, xp: null,
|
||||
first: null, queue: [], queueEnd: -1, needSeek: false, lastT: null };
|
||||
follow = true; frozen = null; unseen = 0; scroll = 0;
|
||||
stopTyping(); draft = ''; sendError = ''; // (another chat: a new message)
|
||||
fans = null; fansScroll = 0;
|
||||
ns.youtubeChat.views({ replay: c.replay }).then(list => {
|
||||
const v = (title && list.find(x => x.title === title)) || list[index] || list[0];
|
||||
return ns.youtubeChat.open(v.token, c.replay);
|
||||
}).then(r => {
|
||||
if (chat !== c) return;
|
||||
c.next = r.next; c.status = r.next ? 'live' : 'ended';
|
||||
if (c.replay) { c.first = r.next; c.needSeek = true; return; } // (a replay starts at the video's place: tickReplay)
|
||||
apply(c, r.items);
|
||||
c.reactions = r.reactions || []; c.input = r.input || null; c.emojis = r.emojis || []; c.support = r.support || [];
|
||||
c.leaderboard = r.leaderboard || null; c.xp = c.leaderboard ? c.leaderboard.xp : null;
|
||||
}, err => {
|
||||
if (chat !== c) return;
|
||||
if (attempt + 1 < START_TRIES) { c.status = 'retry'; c.retryAt = performance.now() + START_RETRY_MS; return; } // (tick tries again)
|
||||
console.warn('[streamxr] live chat unavailable:', err && err.message || err);
|
||||
c.status = 'error';
|
||||
}).then(() => { c.busy = false; c.due = performance.now() + (c.replay ? 0 : POLL_MS); }); // (a replay fetches at once)
|
||||
}
|
||||
|
||||
// Apply a batch of changes (youtube-chat.js) to the chat.
|
||||
function apply(c, changes) {
|
||||
let added = 0;
|
||||
for (const ch of changes) {
|
||||
if (ch.op === 'add' && !c.ids.has(ch.msg.id)) { c.ids.add(ch.msg.id); c.items.push(itemOf(ch.msg)); added++; }
|
||||
else if (ch.op === 'remove') c.items = c.items.filter(it => it.id !== ch.id);
|
||||
else if (ch.op === 'removeAuthor') c.items = c.items.filter(it => it.channelId !== ch.channelId);
|
||||
else if (ch.op === 'banner') c.banner = { id: ch.banner.id, header: ch.banner.header, item: itemOf(ch.banner.msg) };
|
||||
else if (ch.op === 'unbanner' && c.banner && (!ch.id || ch.id === c.banner.id)) c.banner = null;
|
||||
}
|
||||
const max = follow ? MAX_ITEMS : MAX_PAUSED;
|
||||
if (c.items.length > max) c.items = c.items.slice(-max);
|
||||
if (c.ids.size > MAX_PAUSED * 4) c.ids = new Set(c.items.map(it => it.id)); // (forget long-gone ids)
|
||||
if (!follow) unseen += added;
|
||||
if (changes.length) c.version++;
|
||||
}
|
||||
|
||||
function poll(c, now) {
|
||||
c.busy = true;
|
||||
ns.youtubeChat.poll(c.next, c.leaderboard && c.leaderboard.pointsKey).then(r => {
|
||||
if (chat !== c) return;
|
||||
apply(c, r.items); c.next = r.next; c.tries = 0;
|
||||
if (r.xp !== undefined && r.xp !== c.xp) { c.xp = r.xp; c.version++; }
|
||||
if (!r.next) c.status = 'ended';
|
||||
c.due = performance.now() + POLL_MS;
|
||||
}, err => {
|
||||
if (chat !== c) return;
|
||||
if (++c.tries >= MAX_TRIES) { console.warn('[streamxr] live chat stopped:', err && err.message || err); c.status = 'error'; }
|
||||
c.due = performance.now() + RETRY_MS;
|
||||
}).then(() => { c.busy = false; });
|
||||
}
|
||||
|
||||
// A replay, every frame: a seek starts it again; what's due by the video's place shows; more
|
||||
// is fetched while little waits ahead.
|
||||
function tickReplay(c, frame) {
|
||||
const t = Math.round((frame.st.time || 0) * 1000);
|
||||
if (c.lastT != null && (t < c.lastT - SEEK_BACK_MS || t > c.lastT + SEEK_AHEAD_MS)) {
|
||||
c.items = []; c.ids = new Set(); c.banner = null; c.queue = []; c.queueEnd = -1; c.needSeek = true; c.version++;
|
||||
resume();
|
||||
}
|
||||
c.lastT = t;
|
||||
let due = 0;
|
||||
while (due < c.queue.length && c.queue[due].at <= t) due++;
|
||||
if (due) apply(c, c.queue.splice(0, due));
|
||||
if (!c.busy && performance.now() >= c.due && (c.needSeek || (c.next && c.queueEnd - t < REPLAY_AHEAD_MS))) fetchReplay(c, t);
|
||||
}
|
||||
// Fetch a replay's next chunk (or, for a seek, the chunk from a little before the video's place).
|
||||
function fetchReplay(c, t) {
|
||||
const seek = c.needSeek, from = seek ? Math.max(0, t - REPLAY_BACKFILL_MS) : t;
|
||||
c.busy = true;
|
||||
ns.youtubeChat.pollReplay(seek ? c.first : c.next, from).then(r => {
|
||||
if (chat !== c) return;
|
||||
if (seek) { c.queue = []; c.needSeek = false; }
|
||||
const items = r.items.map(ch => (ch.at != null ? ch : { ...ch, at: from }));
|
||||
c.queue.push(...items);
|
||||
if (items.length) c.queueEnd = Math.max(c.queueEnd, items[items.length - 1].at);
|
||||
c.next = r.next;
|
||||
if (!r.next) c.queueEnd = Infinity; // the end of the recorded chat: nothing more to fetch
|
||||
c.tries = 0;
|
||||
c.due = performance.now() + (items.length ? 0 : POLL_MS);
|
||||
}, err => {
|
||||
if (chat !== c) return;
|
||||
if (++c.tries >= MAX_TRIES) { console.warn('[streamxr] chat replay stopped:', err && err.message || err); c.status = 'error'; }
|
||||
c.due = performance.now() + RETRY_MS;
|
||||
}).then(() => { c.busy = false; });
|
||||
}
|
||||
|
||||
// Every frame: while the panel is open, start the chat (or start it again: another video,
|
||||
// another view, live or replay, or reopened after a while) and poll it when due (a replay:
|
||||
// tickReplay).
|
||||
function tick(frame) {
|
||||
if (!host.isOpen()) return;
|
||||
const now = frame.now, views = viewsOf(frame.st), replay = !!(frame.st.liveChat && frame.st.liveChat.replay);
|
||||
if (!views.length) { chat = null; return; }
|
||||
const i = chosen(views);
|
||||
const stale = now - lastOpen > RESTART_MS;
|
||||
lastOpen = now;
|
||||
if (!chat || chat.view !== i || chat.replay !== replay || chat.video !== ns.youtubeData.currentVideoId() || (stale && !chat.busy)) {
|
||||
start(i, views[i].title, replay); return;
|
||||
}
|
||||
if (chat.status === 'retry') { if (performance.now() >= chat.retryAt) start(chat.view, chat.title, chat.replay, chat.attempt + 1); return; }
|
||||
if (chat.replay) { if (chat.status === 'live') tickReplay(chat, frame); }
|
||||
else if (chat.status === 'live' && !chat.busy && now >= chat.due && chat.next) poll(chat, now);
|
||||
if (fans && fans.at && !fans.busy && performance.now() - fans.at > FANS_REFRESH_MS) loadFans();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Top fans
|
||||
function openFans() {
|
||||
if (!chat || !chat.leaderboard) return;
|
||||
fans = { rows: [], you: null, loading: true, error: '', busy: false, at: 0, version: 0 }; fansScroll = 0;
|
||||
loadFans();
|
||||
}
|
||||
function loadFans() {
|
||||
const f = fans;
|
||||
if (!f || !chat || !chat.leaderboard) return;
|
||||
f.busy = true;
|
||||
ns.youtubeChat.topFans(chat.leaderboard.params).then(r => {
|
||||
if (fans !== f) return;
|
||||
f.rows = r.fans; f.you = r.you ? { ...r.you, name: r.you.name || tr('yt.chat.you') } : null; f.error = '';
|
||||
}, err => {
|
||||
if (fans !== f) return;
|
||||
console.warn('[streamxr] top fans unavailable:', err && err.message || err);
|
||||
if (!f.rows.length) f.error = tr('yt.chat.fansError');
|
||||
}).then(() => { f.busy = false; f.loading = false; f.at = performance.now(); f.version++; });
|
||||
}
|
||||
|
||||
function model(frame) {
|
||||
const views = viewsOf(frame.st), i = chosen(views);
|
||||
const tabs = views.length ? views.map((v, k) => ({ id: String(k), label: v.title || tr('yt.panel.chat') })) : [{ id: '0', label: tr('yt.panel.chat') }];
|
||||
let content;
|
||||
if (!views.length) content = { kind: 'message', text: tr('yt.chat.none') };
|
||||
else if (!chat || ((chat.status === 'loading' || chat.status === 'retry') && !chat.items.length)) content = { kind: 'message', text: tr('yt.loading'), spinner: true };
|
||||
else if (chat.status === 'error' && !chat.items.length) content = { kind: 'message', text: tr('yt.chat.error') };
|
||||
else {
|
||||
// (an emoji written without its emoji selector, as YouTube writes ❤, gets one: drawn in colour, not as a plain glyph)
|
||||
const reactions = chat.status === 'live' ? chat.reactions.map((r, k) => ({ id: String(k), emoji: /️$/.test(r.emoji) ? r.emoji : `${r.emoji}️`,
|
||||
on: frame.now - (lit[k] || -Infinity) < REACT_LIT_MS })) : [];
|
||||
content = { kind: 'chat', banner: chat.banner, items: follow ? chat.items : (frozen || chat.items), follow, reactions,
|
||||
input: inputModel(), support: chat.status === 'live' && chat.support.length > 0,
|
||||
xp: chat.leaderboard ? { label: chat.xp || tr('yt.chat.topFans') } : null,
|
||||
fans: fans ? { title: tr('yt.chat.topFans'), loading: fans.loading, error: fans.error, empty: tr('yt.chat.fansEmpty'), rows: fans.rows, you: fans.you } : null,
|
||||
labels: { newest: unseen ? tr('yt.chat.newestCount', { n: unseen }) : tr('yt.chat.newest'), empty: chat.status === 'ended' ? tr('yt.chat.ended') : tr('yt.chat.empty'),
|
||||
send: tr('yt.chat.send'), support: tr('yt.chat.support') } };
|
||||
}
|
||||
const m = { tabs, tab: String(i), content, scroll: fans ? fansScroll : scroll, now: frame.now / 1000 };
|
||||
const loading = !chat || chat.status === 'loading' || chat.status === 'retry';
|
||||
const lits = content.reactions ? content.reactions.map(r => (r.on ? 1 : 0)).join('') : '';
|
||||
const key = [tabs.map(t => t.label).join(','), i, chat ? `${chat.video}:${chat.status}:${follow ? chat.version : 'paused'}` : '',
|
||||
follow, unseen, Math.round(scroll), chat && chat.banner ? chat.banner.id : '', lits, draft, typing, sending, sendError,
|
||||
chat ? chat.xp : '', fans ? `${fans.version}:${Math.round(fansScroll)}` : '',
|
||||
loading || (fans && fans.loading) ? Math.floor(frame.now / 33) : '', ns.i18n.version()].join('#');
|
||||
return { model: m, key };
|
||||
}
|
||||
|
||||
// After a draw: keep the scroll where the style put it; at the end again, follow again.
|
||||
function afterDraw() {
|
||||
if (fans) { fansScroll = host.style.shownScroll(); return; }
|
||||
scroll = host.style.shownScroll();
|
||||
if (!follow && scroll >= host.style.maxScroll() - 2 && frozen) resume();
|
||||
}
|
||||
function resume() { follow = true; frozen = null; unseen = 0; if (chat && chat.items.length > MAX_ITEMS) chat.items = chat.items.slice(-MAX_ITEMS); }
|
||||
|
||||
// ------------------------------------------------------------ typing a message
|
||||
const canType = () => !!(chat && chat.status === 'live' && chat.input && chat.input.params);
|
||||
// The draft in pieces: a channel emoji's shortcut (':_wave:', as its key types it), or the
|
||||
// text between them.
|
||||
function pieces(text) {
|
||||
const map = new Map((chat ? chat.emojis : []).map(e => [e.shortcut, e])), out = [];
|
||||
const re = /:[^:\s]+:/g;
|
||||
let at = 0, mt;
|
||||
while ((mt = re.exec(text))) {
|
||||
const e = map.get(mt[0]);
|
||||
if (!e) continue;
|
||||
if (mt.index > at) out.push({ text: text.slice(at, mt.index) });
|
||||
out.push({ emoji: e }); at = mt.index + mt[0].length;
|
||||
}
|
||||
if (at < text.length) out.push({ text: text.slice(at) });
|
||||
return out;
|
||||
}
|
||||
// How long YouTube counts it: characters, a channel emoji counting as input.emojiCost.
|
||||
const lengthOf = text => pieces(text).reduce((n, p) => n + (p.emoji ? chat.input.emojiCost : Array.from(p.text).length), 0);
|
||||
|
||||
// The message row for the model: why you can't chat, or the field.
|
||||
function inputModel() {
|
||||
const input = chat && chat.status === 'live' ? chat.input : null;
|
||||
if (!input) return null;
|
||||
if (!input.params) return input.restricted ? { note: input.restricted } : null;
|
||||
return { runs: pieces(draft).map(p => (p.emoji ? { image: p.emoji.image, label: p.emoji.shortcut } : { text: p.text })),
|
||||
placeholder: input.placeholder || tr('yt.chat.placeholder'), focused: typing, canSend: !!draft.trim() && !sending, error: sendError };
|
||||
}
|
||||
|
||||
// The keys come to the field (the keyboard's emoji pages starting with the channel's own).
|
||||
function startTyping() {
|
||||
if (!canType()) return;
|
||||
typing = true;
|
||||
host.typeInto(true, { emojis: chat.emojis.map(e => ({ text: e.shortcut, image: e.image, label: e.label })) });
|
||||
}
|
||||
function stopTyping() { if (typing) { typing = false; host.typeInto(false); } }
|
||||
|
||||
// A key from the keyboard: a character (or an emoji, or a channel emoji's shortcut) is
|
||||
// added while it fits YouTube's limit; Backspace takes off the last character or emoji;
|
||||
// Enter sends; Escape stops typing.
|
||||
function key(v) {
|
||||
if (v === 'Escape') { stopTyping(); return; }
|
||||
if (v === 'Enter') { sendDraft(); return; }
|
||||
sendError = '';
|
||||
if (v === 'Backspace') {
|
||||
const last = pieces(draft).pop();
|
||||
if (last) draft = draft.slice(0, draft.length - (last.emoji ? last.emoji.shortcut.length : (Array.from(last.text).pop() || '').length));
|
||||
return;
|
||||
}
|
||||
if (canType() && lengthOf(draft + v) <= chat.input.max) draft += v;
|
||||
}
|
||||
|
||||
// Post the draft (text and channel emojis); cleared once YouTube took it, then the list
|
||||
// goes back to the newest (where it will appear). Typing goes on.
|
||||
function sendDraft() {
|
||||
if (!canType() || sending || !draft.trim()) return;
|
||||
const text = draft, segments = pieces(text).map(p => (p.emoji ? { emojiId: p.emoji.id } : { text: p.text }));
|
||||
sending = true; sendError = '';
|
||||
ns.youtubeChat.send(chat.input, segments).then(ok => {
|
||||
if (ok) { if (draft === text) draft = ''; resume(); } else sendError = tr('yt.chat.sendFailed');
|
||||
}, err => { console.warn('[streamxr] chat message not sent:', err && err.message || err); sendError = tr('yt.chat.sendFailed'); })
|
||||
.then(() => { sending = false; });
|
||||
}
|
||||
|
||||
// Support: YouTube takes payments in its own window, so the dialog says where to go and
|
||||
// what this stream offers there.
|
||||
function support() {
|
||||
const offers = chat ? chat.support.join(', ') : '';
|
||||
host.confirm({ title: tr('yt.chat.supportTitle'), text: offers ? tr('yt.chat.supportTextWith', { offers }) : tr('yt.chat.supportText'),
|
||||
confirm: tr('yt.chat.supportOk'), cancel: null });
|
||||
}
|
||||
|
||||
// Send reaction k (lit at once; a refusal is only logged: YouTube shows nothing either).
|
||||
function react(k) {
|
||||
const r = chat && chat.reactions[k], now = performance.now();
|
||||
if (!r || now - lastReact < REACT_GAP_MS) return;
|
||||
lastReact = now; lit[k] = now;
|
||||
ns.youtubeChat.react(r).then(ok => { if (!ok) console.warn('[streamxr] reaction not taken:', r.emoji); },
|
||||
err => console.warn('[streamxr] reaction failed:', err && err.message || err));
|
||||
}
|
||||
|
||||
function event(ev) {
|
||||
if (ev.type === 'tab') {
|
||||
const k = +ev.value;
|
||||
if (k !== view) { view = k; ns.storage.set(VIEW_KEY, String(k)); chat = null; }
|
||||
} else if (ev.type === 'newest') resume();
|
||||
else if (ev.type === 'react') react(+ev.value);
|
||||
else if (ev.type === 'field') startTyping();
|
||||
else if (ev.type === 'send') sendDraft();
|
||||
else if (ev.type === 'key') { if (typing) key(ev.value); }
|
||||
else if (ev.type === 'support') support();
|
||||
else if (ev.type === 'fans') { if (fans) fans = null; else openFans(); }
|
||||
else if (ev.type === 'fansBack') fans = null;
|
||||
else if (ev.type === 'scroll' && fans) fansScroll = Math.max(0, Math.min(host.style.maxScroll(), ev.value));
|
||||
else if (ev.type === 'scroll') {
|
||||
const max = host.style.maxScroll();
|
||||
scroll = Math.max(0, Math.min(max, ev.value));
|
||||
if (follow && scroll < max - 4) { follow = false; frozen = chat ? chat.items.slice() : null; unseen = 0; } // scrolled back: pause
|
||||
}
|
||||
}
|
||||
|
||||
return { model, event, tick, afterDraw };
|
||||
}
|
||||
|
||||
ns.ytVr = ns.ytVr || {};
|
||||
ns.ytVr.liveChatPanel = { create };
|
||||
})(globalThis.__streamxr = globalThis.__streamxr || {});
|
||||
@@ -11,12 +11,21 @@
|
||||
// symbols.js), style ('queue' | 'catalog' | 'profile' | 'info' | 'options'), size ('wide'
|
||||
// | 'tall' | 'standard', see vr-panel/config.js), overrides (the style's slots, optional),
|
||||
// default (optional: the panel open the first time on its side, before one is
|
||||
// remembered), create(host) -> controller }.
|
||||
// remembered), available (optional, side panels: st => bool, asked every frame: while
|
||||
// false, its toolbar item is hidden and it can't be open), autoOpen (optional: open it
|
||||
// whenever it becomes available, without remembering it; the panel it replaced comes back
|
||||
// when it goes), stays (optional: it doesn't fade with the rest while you watch),
|
||||
// create(host) -> controller }.
|
||||
// host (given by vr-panel.js): { actions (the session's: provider + shell), style (this panel's style instance:
|
||||
// maxScroll(), shownScroll() ...), perPage (cards / rows per page), open(), close(),
|
||||
// isOpen() (this panel), closeMenu() (the controls panel's open menu), confirm(d) (ask
|
||||
// isOpen() (this panel), closeMenu() (the controls panel's open menu), openMenu(control)
|
||||
// (a floating menu on one of this panel's controls, e.g. 'action:bell': its items from the
|
||||
// controller's menu(control) -> { items: [{ id, text, on, disabled, icons }], note, stay },
|
||||
// a choice arriving as { type: 'menu', value: item id, menu: control }), panel(id) (another
|
||||
// panel's controller), confirm(d) (ask
|
||||
// in the shell's dialog: d = { title, text, confirm, cancel, danger } -> Promise of bool),
|
||||
// typeInto(on) (ask
|
||||
// typeInto(on, opts) (opts.emojis: [{ text, image, label }], the panel's own emojis for the
|
||||
// keyboard's emoji pages; ask
|
||||
// for the keys, or give them back (asking brings up the in-VR keyboard): while on and
|
||||
// shown, each key arrives as an event
|
||||
// { type: 'key', value: a character | 'Backspace' | 'Enter' | 'Escape' }) }.
|
||||
@@ -34,6 +43,8 @@
|
||||
// tick(frame) (optional) every frame, shown or not
|
||||
// afterDraw() (optional) after the style drew the model
|
||||
// opened() (optional) its toolbar item (or pill) opened the panel
|
||||
// menu(control) (optional) while a menu it opened (host.openMenu) is up, every
|
||||
// frame: what it lists (null closes it)
|
||||
// ...anything else the controls panel calls (e.g. channel.openChannel) }
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
@@ -47,7 +58,9 @@
|
||||
overrides: footer },
|
||||
{ id: 'channel', zone: 'left', label: () => ns.i18n.t('yt.panel.channel'), icon: 'account_box', style: 'profile', size: 'tall', create: yt.channelPanel.create,
|
||||
overrides: footer }, // an opened post's comments
|
||||
// right of the Safe Zone: what to watch
|
||||
// right of the Safe Zone: a live stream's chat (only then; it stays while you watch), and what to watch
|
||||
{ id: 'chat', zone: 'right', label: () => ns.i18n.t('yt.panel.chat'), icon: 'chat_bubble', style: 'info', size: 'tall', create: yt.liveChatPanel.create,
|
||||
available: st => !!st.liveChat, autoOpen: true, stays: true },
|
||||
{ id: 'recs', zone: 'right', label: () => ns.i18n.t('yt.panel.recs'), icon: 'playlist_play', style: 'queue', size: 'wide', create: yt.upNextPanel.create, default: true },
|
||||
{ id: 'browse', zone: 'right', label: () => ns.i18n.t('yt.panel.browse'), icon: 'explore', style: 'catalog', size: 'wide', create: yt.browsePanel.create }, // Explore
|
||||
{ id: 'you', zone: 'right', label: () => ns.i18n.t('yt.panel.you'), icon: 'person', style: 'catalog', size: 'wide', create: yt.youPanel.create },
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
// setVolume, setMuted, storyboard, skipAd, previousVideo, nextVideo, extra; the rest are
|
||||
// for this site's own panels),
|
||||
// state(): polled every frame: { title, nowPlaying (see nowPlaying below), time, duration,
|
||||
// live, paused, quality: { current, currentLabel, levels: [{ id, label }] }, speed,
|
||||
// live, paused, quality: { current, currentLabel, icons, levels: [{ id, label, icons }] }, speed,
|
||||
// speeds, volume, chapters, heatmap, ad, extras (its own controls panel buttons:
|
||||
// vr-panel.js; used through actions.extra), ...whatever this site's panels read },
|
||||
// ad() -> null | { ... } the ad playing now (the shell shows ads on the cinema screen)
|
||||
@@ -63,6 +63,25 @@
|
||||
return [QUALITY_ICONS[q] || 'high_quality', ...(fps >= 50 ? ['60fps'] : [])];
|
||||
}
|
||||
|
||||
// YouTube Premium's mark (extras.js premiumMark: the Diamond, red with Premium, purple without).
|
||||
const diamond = () => ns.ytVr.premiumMark();
|
||||
|
||||
// The shell's quality state from youtube.quality(): the levels (YouTube's names and icons),
|
||||
// with Premium's enhanced-bitrate one ('premium', e.g. "1080p Premium") just above its plain
|
||||
// twin, marked with the Diamond and greyed out without Premium; it's the current one while
|
||||
// it plays.
|
||||
function qualityState(q) {
|
||||
const p = q.premium;
|
||||
const premiumLevel = p && { id: 'premium', label: p.label.replace(' ', '\n'), icons: [...qualityIcons(p.quality, q.labels).slice(0, 1), diamond()], disabled: !p.playable };
|
||||
const levels = [];
|
||||
for (const id of q.levels || []) {
|
||||
if (premiumLevel && id === p.quality) levels.push(premiumLevel);
|
||||
levels.push({ id, label: qualityName(id, q.labels), icons: qualityIcons(id, q.labels) });
|
||||
}
|
||||
if (p && p.on) return { current: 'premium', currentLabel: p.label, icons: premiumLevel.icons, levels };
|
||||
return { current: q.current, currentLabel: qualityName(q.current, q.labels), icons: qualityIcons(q.current, q.labels), levels };
|
||||
}
|
||||
|
||||
// Pick the format from player data (youtube.info(): projection[], spherical,
|
||||
// partialSpherical, title, keywords, video = "WxH"):
|
||||
// not marked spherical by YouTube -> cinema screen
|
||||
@@ -224,6 +243,10 @@
|
||||
// second). The seek bar then covers the rewindable part (time 0 = its start, duration =
|
||||
// its length) and the time shows the LIVE label (`liveLabel`).
|
||||
const liveNow = cached(() => { const l = youtube.live(); return l.isLive ? l : null; }, 250, null);
|
||||
// The video's chat (youtube-chat.js info(): { replay, views }; re-read every second), or
|
||||
// null: a live stream's live chat, or a finished stream's chat replay (state(): liveChat).
|
||||
const liveChat = cached(() => ns.youtubeChat.info(), 1000, null);
|
||||
const chatFor = l => { const c = liveChat(); return c && (c.replay ? !l : !!l) ? c : null; };
|
||||
const LIVE_SLACK = 12; // seconds behind the live moment that still count as live
|
||||
// How far behind live: the larger of (a) the gap to the rewindable end and (b) the
|
||||
// broadcast delay (now - ingestion) beyond its smallest value seen for this video (the
|
||||
@@ -317,6 +340,10 @@
|
||||
ratePost: (post, what) => ns.youtubeChannel.ratePost(post, what),
|
||||
votePoll: (post, index, on) => ns.youtubeChannel.votePoll(post, index, on),
|
||||
setSubscribed,
|
||||
// A channel's notification bell: one of its notifications.options (youtube-subscriptions.js
|
||||
// setNotifications -> { ok, confirmed, state, error }), and which choice a state means.
|
||||
setBell: option => ns.youtubeSubscriptions.setNotifications(option),
|
||||
bellMatches: (label, state) => ns.youtubeSubscriptions.bellMatches(label, state),
|
||||
// Description panel: the transcript (opens and reads YouTube's transcript panel), the
|
||||
// comments ('top' | 'newest') and a thread's replies (pagers), rating a comment.
|
||||
transcript: () => ns.youtubeContent.transcript(),
|
||||
@@ -330,6 +357,7 @@
|
||||
search: query => ns.youtubeContent.search(query), // the Search panel
|
||||
suggest: text => ns.youtubeHome.suggestions(text), // and its suggestions
|
||||
browseSubscriptions: () => ns.youtubeSubscriptions.feed(),
|
||||
browseChannels: sort => ns.youtubeSubscriptions.channels(sort), // You: Channels (sort: one of p.sorts' texts)
|
||||
browseFeed: name => ns.youtubeHome.feedVideos(name),
|
||||
browsePlaylist: id => ns.youtubeContent.playlistVideos(id),
|
||||
myChannelId: () => ns.youtubeHome.myChannelId(), // You: Your channel
|
||||
@@ -354,20 +382,21 @@
|
||||
title, time: l ? l.current - l.start : youtube.currentTime(), duration: l ? l.end - l.start : youtube.duration(),
|
||||
live: l && liveLabel(l), paused: youtube.isPaused(),
|
||||
buffered: l || ad() ? null : youtube.bufferedEnd(), // the seek bar's loaded part (not on live streams or ads)
|
||||
quality: { current: q.current, currentLabel: qualityName(q.current, q.labels), icons: qualityIcons(q.current, q.labels), levels: (q.levels || []).map(id => ({ id, label: qualityName(id, q.labels) })) },
|
||||
quality: qualityState(q),
|
||||
speed: youtube.speed(), speeds: youtube.speeds(), volume: youtube.volume(), chapters: chapters(), heatmap: l ? [] : heatmap(), ad: ad(),
|
||||
nowPlaying: nowPlaying({ title, channel, details, playlist: pl, subscribeUi: subscribeUi() }),
|
||||
// For YouTube's panels:
|
||||
channelId: ns.youtubeChannel.currentChannelId(), details, playlist: pl, description: description(),
|
||||
recommendations: page.recommendations(),
|
||||
playback: { loop: youtube.loop(), autoplay: !!youtube.autoplay() }, audio: audioTracks(),
|
||||
extras: extras.state({ ad: ad() }),
|
||||
extras: extras.state({ ad: ad(), paused: youtube.isPaused(), live: !!l }),
|
||||
liveChat: chatFor(l), // a live stream's chat, or a finished one's chat replay (live-chat.js)
|
||||
};
|
||||
// No video playing (VR started from the Home page...): what the panels show instead of
|
||||
// the last video's leftovers (YouTube may keep its old player around): a line saying how
|
||||
// to pick one, an empty seek bar, nothing in Up Next or Description.
|
||||
if (watching()) return st;
|
||||
return { ...st, title: tr('yt.noVideo'), time: 0, duration: 0, paused: true, live: null, buffered: null, chapters: [], heatmap: [], ad: null,
|
||||
return { ...st, title: tr('yt.noVideo'), time: 0, duration: 0, paused: true, live: null, buffered: null, chapters: [], heatmap: [], ad: null, liveChat: null,
|
||||
nowPlaying: { info: [{ text: tr('yt.noVideoHint'), color: 'onSurfaceVariant' }], creator: null, actions: [], queue: null },
|
||||
channelId: null, details: {}, playlist: null, description: null, recommendations: [], extras: extras.state({ ad: null, noVideo: true }) };
|
||||
}
|
||||
@@ -377,13 +406,15 @@
|
||||
|
||||
ns.ytVr = ns.ytVr || {};
|
||||
// YouTube's brand colours (its brand page): YouTube Red, Almost Black (vr-panel/tokens.js),
|
||||
// and its card flags' own colours (cards.js): VR180 purple, Mix orange, Playlist green.
|
||||
// its card flags' own colours (cards.js): VR180 purple, Mix orange, Playlist green, and its
|
||||
// live chat's (live-chat.js): the owner's yellow name chip, moderators' blue, members' green.
|
||||
// seed: its Material 3 palette is made from YouTube Red when VR starts (tokens.js
|
||||
// scheme()), in the Fidelity variant, with the pink and the deep red swapped (buttons
|
||||
// pink, accents red); the accents themselves use the exact YouTube Red (`accent`, the
|
||||
// brand role). Decided in docs/Materials-Redesign.md, "The colours".
|
||||
const M3_THEME = { accent: '#FF0033', accentPressed: '#ff4d70', dark: '#212121', onAccent: '#FFFFFF',
|
||||
custom: { vr180: '#b388ff', mix: '#ff8a00', playlist: '#2ecc71' },
|
||||
custom: { vr180: '#b388ff', mix: '#ff8a00', playlist: '#2ecc71',
|
||||
chatOwner: '#ffd600', onChatOwner: '#0f0f0f', chatModerator: '#5e84f1', chatMember: '#2ba640', onChatMember: '#ffffff' },
|
||||
seed: '#FF0033', scheme: 'fidelity',
|
||||
swap: [['primary', 'secondaryContainer'], ['onPrimary', 'onSecondaryContainer']] };
|
||||
// The "YouTube" theme's roles, set over that palette (tokens.js theme.palette): Almost Black
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
'yt.panel.browse': 'Explore',
|
||||
'yt.panel.you': 'You',
|
||||
'yt.panel.recs': 'Up Next',
|
||||
'yt.panel.chat': 'Live chat',
|
||||
'yt.panel.settings': 'Settings',
|
||||
|
||||
// Shared by several panels.
|
||||
@@ -87,6 +88,11 @@
|
||||
'yt.browse.mychannelMissing': 'Couldn’t find your channel. Are you signed in on YouTube?',
|
||||
'yt.browse.subs': 'Subscriptions',
|
||||
'yt.browse.subsNote': 'New videos from the channels you subscribe to',
|
||||
'yt.browse.channels': 'Channels',
|
||||
'yt.browse.channelsNote': 'The channels you subscribe to, A to Z. Pick one to open it.',
|
||||
'yt.bell.title': 'Notifications',
|
||||
'yt.bell.line': 'Notifications: {name}',
|
||||
'yt.jumpAhead': 'Jump ahead',
|
||||
'yt.browse.history': 'History',
|
||||
'yt.browse.historyNote': 'Videos you’ve watched, newest first',
|
||||
'yt.browse.playlists': 'Playlists',
|
||||
@@ -121,6 +127,27 @@
|
||||
'yt.comments.failed': 'Couldn’t load comments ({error}).',
|
||||
'yt.comments.off': 'Comments are turned off for this video.',
|
||||
|
||||
// Live chat panel.
|
||||
'yt.chat.none': 'This video has no live chat.',
|
||||
'yt.chat.empty': 'No messages yet.',
|
||||
'yt.chat.ended': 'The chat has ended.',
|
||||
'yt.chat.error': 'Couldn’t load the chat.',
|
||||
'yt.chat.newest': 'Jump to newest',
|
||||
'yt.chat.newestCount': { one: '{n} new message', other: '{n} new messages' },
|
||||
'yt.chat.newMember': 'New member',
|
||||
'yt.chat.placeholder': 'Chat…',
|
||||
'yt.chat.send': 'Send',
|
||||
'yt.chat.sendFailed': 'Not sent',
|
||||
'yt.chat.support': 'Support on desktop',
|
||||
'yt.chat.supportTitle': 'Support on desktop',
|
||||
'yt.chat.supportText': 'Paying happens in YouTube’s own window: take off your headset and use the $ button under the chat.',
|
||||
'yt.chat.supportTextWith': '{offers}: in YouTube’s own window. Take off your headset and use the $ button under the chat.',
|
||||
'yt.chat.supportOk': 'Got it',
|
||||
'yt.chat.topFans': 'Top fans',
|
||||
'yt.chat.you': 'You',
|
||||
'yt.chat.fansEmpty': 'No top fans yet.',
|
||||
'yt.chat.fansError': 'Couldn’t load the top fans.',
|
||||
|
||||
// Channel panel.
|
||||
'yt.channel.videos': 'Videos',
|
||||
'yt.channel.shorts': 'Shorts',
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
'yt.panel.browse': 'Explorar',
|
||||
'yt.panel.you': 'Tú',
|
||||
'yt.panel.recs': 'A continuación',
|
||||
'yt.panel.chat': 'Chat en directo',
|
||||
'yt.panel.settings': 'Ajustes',
|
||||
|
||||
// Shared by several panels.
|
||||
@@ -77,6 +78,11 @@
|
||||
'yt.browse.mychannelMissing': 'No se encontró tu canal. ¿Has iniciado sesión en YouTube?',
|
||||
'yt.browse.subs': 'Suscripciones',
|
||||
'yt.browse.subsNote': 'Vídeos nuevos de los canales a los que te suscribes',
|
||||
'yt.browse.channels': 'Canales',
|
||||
'yt.browse.channelsNote': 'Los canales a los que te suscribes, de la A a la Z. Elige uno para abrirlo.',
|
||||
'yt.bell.title': 'Notificaciones',
|
||||
'yt.bell.line': 'Notificaciones: {name}',
|
||||
'yt.jumpAhead': 'Saltar adelante',
|
||||
'yt.browse.history': 'Historial',
|
||||
'yt.browse.historyNote': 'Vídeos que has visto, los más recientes primero',
|
||||
'yt.browse.playlists': 'Listas',
|
||||
@@ -111,6 +117,27 @@
|
||||
'yt.comments.failed': 'No se pudieron cargar los comentarios ({error}).',
|
||||
'yt.comments.off': 'Los comentarios están desactivados en este vídeo.',
|
||||
|
||||
// Live chat panel.
|
||||
'yt.chat.none': 'Este vídeo no tiene chat en directo.',
|
||||
'yt.chat.empty': 'Todavía no hay mensajes.',
|
||||
'yt.chat.ended': 'El chat ha terminado.',
|
||||
'yt.chat.error': 'No se pudo cargar el chat.',
|
||||
'yt.chat.newest': 'Ir a lo más reciente',
|
||||
'yt.chat.newestCount': { one: '{n} mensaje nuevo', other: '{n} mensajes nuevos' },
|
||||
'yt.chat.newMember': 'Nuevo miembro',
|
||||
'yt.chat.placeholder': 'Chatea…',
|
||||
'yt.chat.send': 'Enviar',
|
||||
'yt.chat.sendFailed': 'No enviado',
|
||||
'yt.chat.support': 'Apoyar en el escritorio',
|
||||
'yt.chat.supportTitle': 'Apoyar en el escritorio',
|
||||
'yt.chat.supportText': 'Los pagos se hacen en la ventana de YouTube: quítate el visor y usa el botón $ bajo el chat.',
|
||||
'yt.chat.supportTextWith': '{offers}: en la ventana de YouTube. Quítate el visor y usa el botón $ bajo el chat.',
|
||||
'yt.chat.supportOk': 'Entendido',
|
||||
'yt.chat.topFans': 'Fans destacados',
|
||||
'yt.chat.you': 'Tú',
|
||||
'yt.chat.fansEmpty': 'Todavía no hay fans destacados.',
|
||||
'yt.chat.fansError': 'No se pudieron cargar los fans destacados.',
|
||||
|
||||
// Channel panel.
|
||||
'yt.channel.videos': 'Vídeos',
|
||||
'yt.channel.shorts': 'Shorts',
|
||||
|
||||
@@ -84,7 +84,8 @@
|
||||
thumb: bestImage(g.thumbnailRenderer) };
|
||||
}
|
||||
|
||||
// A post (backstagePostRenderer / postRenderer): { id, text, author, published, likes,
|
||||
// A post (backstagePostRenderer / postRenderer): { id, text, author, channelId (the
|
||||
// author's, '' if not given), avatar, published, likes,
|
||||
// images: [urls], video: item | null, comments (the count, "882"), commentsParams (its
|
||||
// own page, for postComments), likeState ('liked' | 'disliked' | 'none': yours),
|
||||
// actions: { like, unlike, dislike, undislike } (endpoints for ratePost; null signed out),
|
||||
@@ -109,6 +110,7 @@
|
||||
dislike: usable(dislike.defaultServiceEndpoint), undislike: usable(dislike.toggledServiceEndpoint) };
|
||||
return {
|
||||
id: p.postId, text: strOf(p.contentText), author: strOf(p.authorText),
|
||||
channelId: dig(p, 'authorEndpoint', 'browseEndpoint', 'browseId') || '', avatar: bestImage(p.authorThumbnail),
|
||||
published: strOf(p.publishedTimeText), likes: strOf(p.voteCount),
|
||||
images, video: att.videoRenderer ? D.fromCompact(att.videoRenderer) : null,
|
||||
comments: strOf(reply.text), commentsParams: dig(reply, 'navigationEndpoint', 'browseEndpoint', 'params') || '',
|
||||
@@ -162,6 +164,7 @@
|
||||
// channel(id) -> Promise of { id, title, badge, handle, subscribers, videoCount,
|
||||
// description (short), avatar, banner, links (e.g. "x.com/somechannel and 4 more links"),
|
||||
// url, rssUrl, keywords, isFamilySafe, subscribed (true/false signed in, else null),
|
||||
// notifications (the bell: { state, options } or null, see bellOf),
|
||||
// tabs: { videos: params, ... } (only the tabs this channel has), aboutToken }. Cached
|
||||
// for 5 minutes.
|
||||
const headerCache = new Map(); // id -> { at, promise }
|
||||
@@ -204,9 +207,45 @@
|
||||
// Signed in: YouTube's saved subscription state (the same subscriptionStateEntity a
|
||||
// Subscribe answer carries); null while signed out or when it isn't sent.
|
||||
subscribed: (() => { const e = findKeyAll(r.frameworkUpdates, 'subscriptionStateEntity')[0]; return e && e.subscribed != null ? !!e.subscribed : null; })(),
|
||||
notifications: bellOf(r),
|
||||
};
|
||||
}
|
||||
|
||||
// The header's notification bell, signed in and subscribed: { state ('ALL', 'OCCASIONAL',
|
||||
// 'OFF' ...: the end of SUBSCRIPTION_NOTIFICATION_STATE_*, or ''), options: [{ label,
|
||||
// params, selected }] } (the choices YouTube's own bell menu sends, as in the Manage list's
|
||||
// notificationOptions), or null when the header has none. UNVERIFIED (written signed out):
|
||||
// each choice is found as a modifyChannelNotificationPreferenceEndpoint inside the header,
|
||||
// named by the nearest enclosing item with a title or text; the state is the
|
||||
// subscriptionNotificationStateEntity the header's button points to (by its store key).
|
||||
function bellOf(r) {
|
||||
const options = [], seen = new Set();
|
||||
const walk = (node, stack, depth) => {
|
||||
if (!node || typeof node !== 'object' || depth > 40) return;
|
||||
if (Array.isArray(node)) { for (const x of node) walk(x, stack, depth + 1); return; }
|
||||
const ep = node.modifyChannelNotificationPreferenceEndpoint;
|
||||
if (ep && ep.params) {
|
||||
if (seen.has(ep.params)) return;
|
||||
seen.add(ep.params);
|
||||
const item = [...stack].reverse().find(a => strOf(a.title) || strOf(a.text));
|
||||
if (item) options.push({ label: strOf(item.title) || strOf(item.text), params: ep.params, selected: !!(item.isSelected || item.selected) });
|
||||
return;
|
||||
}
|
||||
stack.push(node);
|
||||
for (const k of Object.keys(node)) walk(node[k], stack, depth + 1);
|
||||
stack.pop();
|
||||
};
|
||||
walk(r.header, [], 0);
|
||||
if (!options.length) return null;
|
||||
const key = findKey(r.header, 'notificationStateEntityStoreKey');
|
||||
const ents = (dig(r, 'frameworkUpdates', 'entityBatchUpdate', 'mutations') || [])
|
||||
.filter(m => findKey(m.payload, 'subscriptionNotificationStateEntity'));
|
||||
const ent = ents.find(m => key && m.entityKey === key) || (ents.length === 1 ? ents[0] : null);
|
||||
const state = String(dig(findKey(ent && ent.payload, 'subscriptionNotificationStateEntity'), 'state') || '')
|
||||
.replace(/^SUBSCRIPTION_NOTIFICATION_STATE_/, '');
|
||||
return { state, options };
|
||||
}
|
||||
|
||||
// The browse params for one of a channel's tabs (its own, or the usual ones).
|
||||
const tabParams = async (channelId, name) => (await channel(channelId)).tabs[name] || TAB_PARAMS[name];
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
// A live stream's chat, read the way YouTube's own chat frame reads it (docs/youtube.md,
|
||||
// "Live chat"): the watch page says there is a chat and names its views (Top chat, Live chat);
|
||||
// the chat frame's data (or the pop-out chat page's, when the page shows no chat) holds each
|
||||
// view's token; a view's chat page (/live_chat?continuation=<that token>) carries the recent
|
||||
// messages and the token to poll with, and live_chat/get_live_chat answers each poll with
|
||||
// what's new and the next token. (The watch page's own view tokens load an empty chat page.)
|
||||
// A finished stream's chat replay works the same way on /live_chat_replay pages, but its
|
||||
// requests (live_chat/get_live_chat_replay) are a cursor through the recorded chat: each gives
|
||||
// the next chunk of messages, each stamped with its time in the video (`at`, ms), and the first
|
||||
// token with another position starts again there (a seek).
|
||||
// Turns YouTube's chat items into plain messages; knows nothing about VR.
|
||||
//
|
||||
// ns.youtubeChat.info() -> null | { replay, views: [{ title, selected }] }
|
||||
// the watch page's chat (null when the video has none; replay: a finished stream's)
|
||||
// await ns.youtubeChat.views(opts) -> [{ title, selected, token }] the views with the
|
||||
// tokens that load them (from the chat frame, else the pop-out page; kept a minute per
|
||||
// video); opts.skipFrame: ask the pop-out page (for checking it); opts.replay: a replay's
|
||||
// await ns.youtubeChat.open(token, replay) -> { items, next, reactions, input, emojis, support, leaderboard }
|
||||
// (replay: a chat replay's page, its `next` the start of the recorded chat)
|
||||
// a view's chat page; reactions: the reaction buttons (❤ 😄 🎉 ...; [] when the stream has
|
||||
// none, or signed out): [{ emoji (the character), label ("Send ❤"), token (what sends it) }];
|
||||
// input: the message box, { placeholder, max (characters), emojiCost (characters an emoji
|
||||
// counts for), params, prefix } or { restricted: why you can't chat }, or null (signed
|
||||
// out); emojis: the channel's own [{ id, shortcut (':_wave:'), image, label }]; support:
|
||||
// what its "$" menu offers ("Super Chat", "Gifts" ...); leaderboard: the Top fans button,
|
||||
// { params (loads the list), pointsKey, xp (your XP: "120 XP", or null) }, or null
|
||||
// await ns.youtubeChat.poll(next, pointsKey) -> { items, next, xp } what's new since `next`
|
||||
// (xp: your XP when YouTube sent a new value, else undefined)
|
||||
// await ns.youtubeChat.pollReplay(next, atMs) -> { items, next } a replay's next chunk
|
||||
// (with the first token: the chunk from atMs on); its items carry `at`
|
||||
// await ns.youtubeChat.topFans(params) -> { fans: [fan], you: fan | null } the Top fans
|
||||
// list (fan: { rank ("1"), name, avatar, points ("30 XP"), badge, note (your row: YouTube's
|
||||
// line under it) }; badge: { title ("#1"), bg, fg } | null)
|
||||
// await ns.youtubeChat.react(reaction) -> true | false send one (YouTube's feedback request)
|
||||
// await ns.youtubeChat.send(input, segments) -> true | false post a message: segments
|
||||
// [{ text } | { emojiId }] (a channel emoji by its id)
|
||||
// items: changes in order (in a replay, each with `at`: its time in the video, ms), each one of
|
||||
// { op: 'add', msg } a new message (see below)
|
||||
// { op: 'remove', id } a message taken down (deleted, or replaced by a removal)
|
||||
// { op: 'removeAuthor', channelId } every message by someone (banned / timed out)
|
||||
// { op: 'banner', banner } the pinned message: { id, header, msg }
|
||||
// { op: 'unbanner', id } the pinned message taken down
|
||||
// next: { token, timeoutMs } to poll with, or null when the chat has ended.
|
||||
// msg: { id, kind ('text' | 'paid' | 'sticker' | 'member' | 'notice'), author, avatar,
|
||||
// channelId, role ('owner' | 'moderator' | 'member' | null), verified, badge (a member
|
||||
// badge's image URL or ''), rank (a Top fan's mark before the name: { title ("#1"), bg, fg }
|
||||
// or null), runs: [{ text } | { emoji: { url, label } }], time (ms),
|
||||
// amount ('$5.00', paid and sticker), header (a membership's line, e.g. "Member for 6
|
||||
// months"), sticker (its image URL), colors ({ header, body, text } '#rrggbb', paid and
|
||||
// sticker) }
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
const D = ns.youtubeData;
|
||||
const { dig, findKey, strOf } = D;
|
||||
|
||||
// ---------------------------------------------------------------- the watch page's chat
|
||||
// A chat's view selector -> [{ title, selected, token }] (only views with a token).
|
||||
function viewsIn(data) {
|
||||
const items = (findKey(data, 'sortFilterSubMenuRenderer') || {}).subMenuItems || [];
|
||||
return items.map(v => ({ title: v.title || '', selected: !!v.selected, token: dig(findKey(v, 'reloadContinuationData'), 'continuation') || '' }))
|
||||
.filter(v => v.token);
|
||||
}
|
||||
|
||||
function info() {
|
||||
const lc = findKey(D.watchData(), 'liveChatRenderer');
|
||||
if (!lc) return null;
|
||||
const views = viewsIn(lc).map(({ title, selected }) => ({ title, selected }));
|
||||
return { replay: !!lc.isReplay, views: views.length ? views : [{ title: '', selected: true }] };
|
||||
}
|
||||
|
||||
// The chat in a page's data, in either shape: a chat update's `liveChatContinuation` (the
|
||||
// chat frame, a view's chat page) or `liveChatRenderer`; both have actions and continuations.
|
||||
const chatIn = data => findKey(data, 'liveChatContinuation') || findKey(data, 'liveChatRenderer') || null;
|
||||
|
||||
let viewCache = null; // { video, at (ms), list }
|
||||
const VIEW_CACHE_MS = 60000;
|
||||
async function views(opts = {}) {
|
||||
const video = D.currentVideoId();
|
||||
if (!opts.skipFrame && viewCache && viewCache.video === video && Date.now() - viewCache.at < VIEW_CACHE_MS) return viewCache.list;
|
||||
let list = [];
|
||||
if (!opts.skipFrame) { // the chat frame YouTube shows beside a live stream (same origin)
|
||||
try {
|
||||
const f = document.querySelector('#chatframe, ytd-live-chat-frame iframe');
|
||||
list = viewsIn(chatIn(f && f.contentWindow && f.contentWindow.ytInitialData));
|
||||
} catch (_) { /* no frame, or not readable */ }
|
||||
}
|
||||
if (!list.length) list = viewsIn(chatIn(await page(`/live_chat${opts.replay ? '_replay' : ''}?is_popout=1&v=${encodeURIComponent(video)}`)));
|
||||
if (!list.length) throw new Error('live_chat: no chat views found');
|
||||
if (!opts.skipFrame) viewCache = { video, at: Date.now(), list };
|
||||
return list;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- requests
|
||||
// The JSON object a page's HTML assigns to window["name"] (brace matching, skipping
|
||||
// strings), or null.
|
||||
function jsonAfter(text, name) {
|
||||
const at = text.indexOf(`${name}"] = {`);
|
||||
if (at < 0) return null;
|
||||
const i = text.indexOf('{', at);
|
||||
let depth = 0, inStr = false, esc = false;
|
||||
for (let j = i; j < text.length; j++) {
|
||||
const ch = text[j];
|
||||
if (inStr) { if (esc) esc = false; else if (ch === '\\') esc = true; else if (ch === '"') inStr = false; continue; }
|
||||
if (ch === '"') inStr = true;
|
||||
else if (ch === '{') depth++;
|
||||
else if (ch === '}' && --depth === 0) { try { return JSON.parse(text.slice(i, j + 1)); } catch (_) { return null; } }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The poll token in a chat update: { token, timeoutMs } or null (the chat ended). In a
|
||||
// replay, the cursor's token (liveChatReplayContinuationData), with `seek` too when YouTube
|
||||
// gives a playerSeekContinuationData.
|
||||
function nextOf(lcc) {
|
||||
const list = (lcc && lcc.continuations) || [], c = list[0] || {};
|
||||
const d = c.invalidationContinuationData || c.timedContinuationData || c.liveChatReplayContinuationData || c.reloadContinuationData || null;
|
||||
if (!d || !d.continuation) return null;
|
||||
const seek = list.map(x => x.playerSeekContinuationData).find(Boolean);
|
||||
return { token: d.continuation, timeoutMs: +d.timeoutMs || 10000, ...(seek && seek.continuation ? { seek: seek.continuation } : {}) };
|
||||
}
|
||||
|
||||
// A chat page's data (its ytInitialData), or null.
|
||||
async function page(url) {
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`live_chat: HTTP ${res.status}`);
|
||||
return jsonAfter(await res.text(), 'ytInitialData');
|
||||
}
|
||||
|
||||
// Load a view's chat page: its recent messages (and pinned message), the token to poll with
|
||||
// and the reaction buttons.
|
||||
async function open(token, replay) {
|
||||
const data = await page(`/live_chat${replay ? '_replay' : ''}?continuation=${encodeURIComponent(token)}`), lcc = chatIn(data);
|
||||
if (!lcc) throw new Error('live_chat: no chat data in the page');
|
||||
return { items: changes(lcc.actions), next: nextOf(lcc), reactions: reactionsIn(lcc), input: inputIn(lcc), emojis: emojisIn(lcc), support: supportIn(lcc),
|
||||
leaderboard: leaderboardIn(lcc, data) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Top fans
|
||||
// The Top fans button in the chat's header (liveViewerLeaderboardChatEntryPointViewModel):
|
||||
// the params that load its list, and your XP (an entity YouTube sends under its key).
|
||||
function leaderboardIn(lcc, data) {
|
||||
const entry = findKey(lcc && lcc.header, 'liveViewerLeaderboardChatEntryPointViewModel');
|
||||
const params = entry && dig(findKey(entry, 'showEngagementPanelEndpoint'), 'globalConfiguration', 'params');
|
||||
if (!params) return null;
|
||||
return { params, pointsKey: entry.pointsEntityKey || '', xp: xpIn(data, entry.pointsEntityKey) || null };
|
||||
}
|
||||
// Your XP in a response's entity updates ("120 XP"), or undefined when it has none.
|
||||
function xpIn(resp, key) {
|
||||
const m = (dig(resp, 'frameworkUpdates', 'entityBatchUpdate', 'mutations') || [])
|
||||
.find(x => x && x.payload && x.payload.liveViewerLeaderboardPointsEntity && (!key || x.entityKey === key));
|
||||
return m ? m.payload.liveViewerLeaderboardPointsEntity.pointsCompactText || '' : undefined;
|
||||
}
|
||||
// A Top fan's mark (a crown button titled "#1" in YouTube's colours), or null.
|
||||
function rankOf(buttons) {
|
||||
const b = (buttons || []).map(x => x && x.buttonViewModel).find(x => x && x.title);
|
||||
return b ? { title: b.title, bg: hex(b.customBackgroundColor), fg: hex(b.customFontColor) } : null;
|
||||
}
|
||||
// One row of the list (liveLeaderboardItemViewModel).
|
||||
function fanOf(vm) {
|
||||
if (!vm) return null;
|
||||
const who = dig(vm, 'content', 'liveLeaderboardItemChannelContentViewModel') || {};
|
||||
return { rank: dig(vm, 'rank', 'content') || '', points: dig(vm, 'points', 'content') || '', name: dig(who, 'displayName', 'content') || '',
|
||||
avatar: dig(vm, 'avatar', 'avatarViewModel', 'image', 'sources', 0, 'url') || '', badge: rankOf([who.badge]),
|
||||
note: dig(vm, 'content', 'liveLeaderboardItemActionContentViewModel', 'actionDescription', 'content') || '' };
|
||||
}
|
||||
// The list, as YouTube's Top fans panel loads it.
|
||||
async function topFans(params) {
|
||||
const r = await D.innertube('get_panel', { panelId: 'PAlive_viewer_leaderboard', params });
|
||||
const panel = dig(r, 'content', 'engagementPanelSectionListRenderer') || {};
|
||||
const rows = (dig(panel, 'content', 'sectionListRenderer', 'contents') || []).map(s => fanOf(findKey(s, 'liveLeaderboardItemViewModel'))).filter(Boolean);
|
||||
return { fans: rows, you: fanOf(dig(panel, 'footer', 'liveLeaderboardItemViewModel')) };
|
||||
}
|
||||
|
||||
// The message box (liveChatMessageInputRenderer: its field and send endpoint), or why you
|
||||
// can't chat (whatever else the action panel holds: members only, sign in ...), or null.
|
||||
function inputIn(lcc) {
|
||||
const panel = lcc && lcc.actionPanel;
|
||||
if (!panel) return null;
|
||||
const box = panel.liveChatMessageInputRenderer;
|
||||
const send = box && findKey(box.sendButton, 'sendLiveChatMessageEndpoint');
|
||||
if (!box || !send || !send.params) return { restricted: strOf(findKey(panel, 'message')) || strOf(findKey(panel, 'text')) || '' };
|
||||
const field = findKey(box, 'liveChatTextInputFieldRenderer') || {};
|
||||
return { placeholder: strOf(field.placeholder), max: +field.maxCharacterLimit || 200, emojiCost: +field.emojiCharacterCount || 1,
|
||||
params: send.params, prefix: send.clientIdPrefix || '' };
|
||||
}
|
||||
// The channel's own emojis (its members' ones): their ids, first shortcut and picture.
|
||||
function emojisIn(lcc) {
|
||||
return ((lcc && lcc.emojis) || []).filter(e => e.isCustomEmoji && e.emojiId && e.shortcuts && e.shortcuts[0])
|
||||
.map(e => ({ id: e.emojiId, shortcut: e.shortcuts[0], image: thumb(e.image), label: e.shortcuts[0] }));
|
||||
}
|
||||
// What the "$" menu (the product picker) offers: its items' titles.
|
||||
function supportIn(lcc) {
|
||||
const picker = findKey(lcc && lcc.actionPanel, 'liveChatProductPickerPanelViewModel');
|
||||
return ((picker && picker.items) || []).map(it => strOf(findKey(it, 'title') || findKey(it, 'text'))).filter(Boolean);
|
||||
}
|
||||
|
||||
// Post a message; true when YouTube took it (its answer echoes it back as a chat item).
|
||||
let sent = 0;
|
||||
async function send(input, segments) {
|
||||
const r = await D.innertube('live_chat/send_message', { params: input.params, clientMessageId: `${input.prefix}${Date.now()}${sent++}`,
|
||||
richMessage: { textSegments: segments } });
|
||||
return !!(r && !r.errors && (r.actions || []).some(a => a.addChatItemAction));
|
||||
}
|
||||
|
||||
// The reaction buttons in a chat's message box (reactionControlPanelViewModel's
|
||||
// expandedButtons): each one's emoji, label and feedback token.
|
||||
function reactionsIn(lcc) {
|
||||
const panel = findKey(lcc && lcc.actionPanel, 'reactionControlPanelViewModel') || {};
|
||||
return (panel.expandedButtons || []).map(b => {
|
||||
const v = b.reactionControlPanelButtonViewModel || {};
|
||||
return { emoji: v.emojiId || '', label: v.a11yLabel || v.emojiId || '', token: dig(findKey(v.onTap, 'feedbackEndpoint'), 'feedbackToken') || '' };
|
||||
}).filter(r => r.emoji && r.token);
|
||||
}
|
||||
|
||||
// Send a reaction: YouTube's feedback request with its token. True when YouTube took it.
|
||||
async function react(reaction) {
|
||||
const r = await D.innertube('feedback', { feedbackTokens: [reaction.token], isFeedbackTokenUnencrypted: false, shouldMerge: false });
|
||||
const answers = (r && r.feedbackResponses) || [];
|
||||
return !answers.some(a => a && a.isProcessed === false);
|
||||
}
|
||||
|
||||
// What's new since the last poll.
|
||||
async function poll(next, pointsKey) {
|
||||
const r = await D.innertube('live_chat/get_live_chat', { continuation: next.token });
|
||||
const lcc = dig(r, 'continuationContents', 'liveChatContinuation');
|
||||
return { items: changes(lcc && lcc.actions), next: nextOf(lcc), xp: xpIn(r, pointsKey) };
|
||||
}
|
||||
|
||||
// A replay's next chunk, the player at atMs (with the replay's first token: the chunk from
|
||||
// there on). Its changes carry `at`.
|
||||
async function pollReplay(next, atMs) {
|
||||
const r = await D.innertube('live_chat/get_live_chat_replay', { continuation: next.token,
|
||||
currentPlayerState: { playerOffsetMs: String(Math.max(0, Math.round(atMs || 0))) } });
|
||||
const lcc = dig(r, 'continuationContents', 'liveChatContinuation');
|
||||
return { items: changes(lcc && lcc.actions), next: nextOf(lcc) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- reading items
|
||||
// ARGB number -> '#rrggbb' ('' for none).
|
||||
const hex = n => (typeof n === 'number' ? `#${((n >>> 0) & 0xffffff).toString(16).padStart(6, '0')}` : '');
|
||||
const usec = t => (t ? Math.round(+t / 1000) : 0);
|
||||
const thumb = obj => { const l = (obj && obj.thumbnails) || []; const t = l[l.length - 1]; return t && t.url ? t.url.replace(/^\/\//, 'https://') : ''; };
|
||||
|
||||
// A message's text pieces: words as they are; an emoji as its picture (a channel's own
|
||||
// emoji) or as its character (a standard one, drawn by the font).
|
||||
function runsOf(t) {
|
||||
if (!t) return [];
|
||||
if (!t.runs) { const s = strOf(t); return s ? [{ text: s }] : []; }
|
||||
return t.runs.map(r => {
|
||||
if (!r.emoji) return { text: r.text || '' };
|
||||
const e = r.emoji;
|
||||
if (!e.isCustomEmoji && e.emojiId && !/\//.test(e.emojiId)) return { text: e.emojiId };
|
||||
return { emoji: { url: thumb(e.image), label: (e.shortcuts && e.shortcuts[0]) || strOf(dig(e, 'image', 'accessibility', 'accessibilityData', 'label')) || '' } };
|
||||
}).filter(r => r.emoji || r.text);
|
||||
}
|
||||
|
||||
// Who wrote it: name, picture, channel, and what the badges say (owner, moderator, member
|
||||
// with the member badge's picture, verified).
|
||||
function authorOf(r) {
|
||||
const a = { author: strOf(r.authorName), avatar: thumb(r.authorPhoto), channelId: r.authorExternalChannelId || '', role: null, verified: false, badge: '' };
|
||||
for (const b of r.authorBadges || []) {
|
||||
const lb = b.liveChatAuthorBadgeRenderer || {};
|
||||
const type = dig(lb, 'icon', 'iconType') || '';
|
||||
if (type === 'OWNER') a.role = 'owner';
|
||||
else if (type === 'MODERATOR') { if (a.role !== 'owner') a.role = 'moderator'; }
|
||||
else if (type === 'VERIFIED') a.verified = true;
|
||||
else if (lb.customThumbnail) { a.badge = thumb(lb.customThumbnail); if (!a.role) a.role = 'member'; }
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// One chat item -> a message, or null for what isn't shown (placeholders: messages YouTube
|
||||
// holds back; anything unknown).
|
||||
function messageOf(item) {
|
||||
const key = item && Object.keys(item)[0], r = key && item[key];
|
||||
if (!r) return null;
|
||||
const base = { id: r.id || '', time: usec(r.timestampUsec) };
|
||||
switch (key) {
|
||||
case 'liveChatTextMessageRenderer':
|
||||
return { ...base, kind: 'text', ...authorOf(r), rank: rankOf(r.beforeContentButtons), runs: runsOf(r.message) };
|
||||
case 'liveChatPaidMessageRenderer':
|
||||
return { ...base, kind: 'paid', ...authorOf(r), runs: runsOf(r.message), amount: strOf(r.purchaseAmountText),
|
||||
colors: { header: hex(r.headerBackgroundColor), body: hex(r.bodyBackgroundColor), text: hex(r.bodyTextColor) || hex(r.headerTextColor) } };
|
||||
case 'liveChatPaidStickerRenderer':
|
||||
return { ...base, kind: 'sticker', ...authorOf(r), runs: [], amount: strOf(r.purchaseAmountText), sticker: thumb(r.sticker),
|
||||
colors: { header: hex(r.backgroundColor), body: hex(r.backgroundColor), text: hex(r.authorNameTextColor) || hex(r.moneyChipTextColor) } };
|
||||
case 'liveChatMembershipItemRenderer':
|
||||
return { ...base, kind: 'member', ...authorOf(r), runs: runsOf(r.message), header: strOf(r.headerPrimaryText) || strOf(r.headerSubtext) };
|
||||
case 'liveChatSponsorshipsGiftPurchaseAnnouncementRenderer': {
|
||||
const h = dig(r, 'header', 'liveChatSponsorshipsHeaderRenderer') || {};
|
||||
return { ...base, kind: 'member', ...authorOf(h), runs: [], header: strOf(h.primaryText) };
|
||||
}
|
||||
case 'liveChatSponsorshipsGiftRedemptionAnnouncementRenderer':
|
||||
return { ...base, kind: 'member', ...authorOf(r), runs: runsOf(r.message), header: '' };
|
||||
case 'liveChatViewerEngagementMessageRenderer':
|
||||
case 'liveChatModeChangeMessageRenderer':
|
||||
return { ...base, kind: 'notice', author: '', avatar: '', channelId: '', role: null, verified: false, badge: '',
|
||||
runs: [...runsOf(r.message || r.text), ...(r.subtext ? [{ text: ` ${strOf(r.subtext)}` }] : [])] };
|
||||
default:
|
||||
return null; // placeholders and anything new
|
||||
}
|
||||
}
|
||||
|
||||
// A chat update's actions -> the changes they make (see the top of the file), in order.
|
||||
function changes(actions) {
|
||||
const out = [];
|
||||
for (const a of actions || []) {
|
||||
if (a.replayChatItemAction) { // a replay's: stamped with its time in the video
|
||||
const at = +a.replayChatItemAction.videoOffsetTimeMsec || 0;
|
||||
for (const ch of changes(a.replayChatItemAction.actions)) out.push({ ...ch, at });
|
||||
continue;
|
||||
}
|
||||
if (a.addChatItemAction) {
|
||||
const msg = messageOf(a.addChatItemAction.item);
|
||||
if (msg && msg.id) out.push({ op: 'add', msg });
|
||||
} else if (a.markChatItemAsDeletedAction || a.removeChatItemAction) {
|
||||
const id = (a.markChatItemAsDeletedAction || a.removeChatItemAction).targetItemId;
|
||||
if (id) out.push({ op: 'remove', id });
|
||||
} else if (a.markChatItemsByAuthorAsDeletedAction || a.removeChatItemByAuthorAction) {
|
||||
const channelId = (a.markChatItemsByAuthorAsDeletedAction || a.removeChatItemByAuthorAction).externalChannelId;
|
||||
if (channelId) out.push({ op: 'removeAuthor', channelId });
|
||||
} else if (a.replaceChatItemAction) {
|
||||
const ra = a.replaceChatItemAction, msg = messageOf(ra.replacementItem);
|
||||
if (msg && msg.id) out.push({ op: 'remove', id: ra.targetItemId }, { op: 'add', msg });
|
||||
} else if (a.addBannerToLiveChatCommand) {
|
||||
const b = dig(a.addBannerToLiveChatCommand, 'bannerRenderer', 'liveChatBannerRenderer') || {};
|
||||
const msg = messageOf(b.contents); // a pinned message (a chat summary isn't a message: skipped)
|
||||
if (msg && b.bannerType !== 'LIVE_CHAT_BANNER_TYPE_CHAT_SUMMARY') {
|
||||
out.push({ op: 'banner', banner: { id: b.actionId || msg.id, header: strOf(dig(b, 'header', 'liveChatBannerHeaderRenderer', 'text')), msg } });
|
||||
}
|
||||
} else if (a.removeBannerForLiveChatCommand) {
|
||||
out.push({ op: 'unbanner', id: a.removeBannerForLiveChatCommand.targetActionId || '' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
ns.youtubeChat = { info, views, open, poll, pollReplay, react, send, topFans, changes };
|
||||
})(globalThis.__streamxr = globalThis.__streamxr || {});
|
||||
@@ -180,12 +180,15 @@
|
||||
}
|
||||
|
||||
// In-page navigation through YouTube's app (no page reload). Returns false if unavailable.
|
||||
// The event is sent from the page manager (inside the app), not the app element itself:
|
||||
// sent from ytd-app it's ignored on some pages (a channel page, checked 2026-10-17), while
|
||||
// from ytd-page-manager it works there and from the watch page alike.
|
||||
function navigate(url, watchEndpoint) {
|
||||
const app = document.querySelector('ytd-app');
|
||||
if (!app) return false;
|
||||
const from = document.querySelector('ytd-page-manager') || document.querySelector('ytd-app');
|
||||
if (!from) return false;
|
||||
const endpoint = { commandMetadata: { webCommandMetadata: { url, webPageType: 'WEB_PAGE_TYPE_WATCH', rootVe: 3832 } }, watchEndpoint };
|
||||
try {
|
||||
app.dispatchEvent(new CustomEvent('yt-navigate', { bubbles: true, composed: true, detail: { endpoint } }));
|
||||
from.dispatchEvent(new CustomEvent('yt-navigate', { bubbles: true, composed: true, detail: { endpoint } }));
|
||||
return true;
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
@@ -55,30 +55,83 @@
|
||||
// Video quality. YouTube ids: 'hd2160', 'hd1440', 'hd1080', 'hd720', 'large' (480p),
|
||||
// 'medium' (360p), 'small', 'tiny', 'auto'. labels: YouTube's own name for each id where
|
||||
// the player gives one (getAvailableQualityData's qualityLabel, e.g. "1080p60" on a 60 fps
|
||||
// video; Premium's extra "1080p Premium" entry is skipped in favour of the plain one).
|
||||
// video; Premium's extra entry is kept apart as `premium`).
|
||||
// premium: YouTube Premium's enhanced-bitrate quality, or null when the video has none:
|
||||
// { quality (its id, 'hd1080'), label ("1080p Premium"), formatId, playable (false: you
|
||||
// don't have Premium), on (playing now) }. Seen signed in with Premium (2026-10-17): an
|
||||
// entry with paygatedQualityDetails and its own formatId ("721") beside the plain
|
||||
// 1080p's ("399"); getPlaybackQualityLabel() names the one playing.
|
||||
// formats: the plain entries' formatIds by id, where the player gives one.
|
||||
function quality() {
|
||||
try {
|
||||
const p = player();
|
||||
const levels = (p && p.getAvailableQualityLevels && p.getAvailableQualityLevels()) || [];
|
||||
const current = (p && p.getPlaybackQuality && p.getPlaybackQuality()) || null;
|
||||
const labels = {};
|
||||
const labels = {}, formats = {};
|
||||
let premium = null;
|
||||
const data = (p && p.getAvailableQualityData && p.getAvailableQualityData()) || [];
|
||||
for (const d of data) {
|
||||
if (!d || !d.quality || !d.qualityLabel) continue;
|
||||
const premium = !!d.paygatedQualityDetails || /premium/i.test(d.qualityLabel);
|
||||
if (!labels[d.quality] || !premium) labels[d.quality] = premium ? labels[d.quality] || d.qualityLabel : d.qualityLabel;
|
||||
if (d.paygatedQualityDetails || /premium/i.test(d.qualityLabel)) {
|
||||
if (!premium) premium = { quality: d.quality, label: d.qualityLabel, formatId: d.formatId || null, playable: d.isPlayable !== false, on: false };
|
||||
continue;
|
||||
}
|
||||
if (!labels[d.quality]) { labels[d.quality] = d.qualityLabel; if (d.formatId) formats[d.quality] = d.formatId; }
|
||||
}
|
||||
return { current, levels, labels };
|
||||
} catch (_) { return { current: null, levels: [], labels: {} }; }
|
||||
if (premium) {
|
||||
const now = (p.getPlaybackQualityLabel && p.getPlaybackQualityLabel()) || '';
|
||||
premium.on = current === premium.quality && now === premium.label;
|
||||
}
|
||||
return { current, levels, labels, formats, premium };
|
||||
} catch (_) { return { current: null, levels: [], labels: {}, formats: {}, premium: null }; }
|
||||
}
|
||||
|
||||
// Choose a quality (one of the ids above). YouTube has changed which method works over
|
||||
// time, so both are tried; whichever the player lacks is simply skipped.
|
||||
// Choose a quality (one of the ids above, or 'premium': Premium's enhanced-bitrate one).
|
||||
// Where the player gives a format id it's passed too (it tells the plain 1080p and 1080p
|
||||
// Premium apart). YouTube has changed which method works over time, so both are tried;
|
||||
// whichever the player lacks is simply skipped.
|
||||
function setQuality(level) {
|
||||
const p = player();
|
||||
if (!p) return;
|
||||
try { if (p.setPlaybackQualityRange) p.setPlaybackQualityRange(level, level); } catch (_) { /* older player */ }
|
||||
try { if (p.setPlaybackQuality) p.setPlaybackQuality(level); } catch (_) { /* newer player */ }
|
||||
const q = quality();
|
||||
let id = level, formatId = q.formats[level];
|
||||
if (level === 'premium') {
|
||||
if (!q.premium || !q.premium.playable) return;
|
||||
id = q.premium.quality; formatId = q.premium.formatId;
|
||||
}
|
||||
try { if (p.setPlaybackQualityRange) p.setPlaybackQualityRange(id, id, formatId || undefined); } catch (_) { /* older player */ }
|
||||
if (level !== 'premium') try { if (p.setPlaybackQuality) p.setPlaybackQuality(id); } catch (_) { /* newer player */ }
|
||||
}
|
||||
|
||||
// YouTube Premium's Jump ahead: to the point most viewers skip to. YouTube offers it as a pill
|
||||
// in the player's "timely actions" overlay at the moments it applies (seen signed in with
|
||||
// Premium, 2026-10-17: ytw-timely-action-view-model > button, its text "Jump ahead"), and a
|
||||
// script's click on that pill jumps (17 s -> 124 s). The pill is found by its text, so this
|
||||
// expects YouTube in English, like the other readers that match YouTube's words.
|
||||
// jumpAheadOffered() -> is the pill on screen now? jumpAhead() clicks it (if it is).
|
||||
function jumpAheadButton() {
|
||||
const p = player();
|
||||
if (!p) return null;
|
||||
for (const b of p.querySelectorAll('.ytp-timely-actions-overlay button, ytw-timely-action-view-model button')) {
|
||||
if (b.getClientRects().length && /jump ahead/i.test((b.getAttribute('aria-label') || '') + ' ' + (b.textContent || ''))) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const jumpAheadOffered = () => !!jumpAheadButton();
|
||||
function jumpAhead() {
|
||||
const b = jumpAheadButton();
|
||||
if (b) b.click();
|
||||
}
|
||||
|
||||
// Does the signed-in account have YouTube Premium? The page's top-bar logo is YouTube
|
||||
// Premium's (seen signed in with Premium, 2026-10-17: topbarLogoRenderer iconType
|
||||
// 'YOUTUBE_PREMIUM_LOGO'; 'YOUTUBE_LOGO' otherwise).
|
||||
function hasPremium() {
|
||||
try {
|
||||
const logo = window.ytInitialData && window.ytInitialData.topbar && window.ytInitialData.topbar.desktopTopbarRenderer
|
||||
&& window.ytInitialData.topbar.desktopTopbarRenderer.logo;
|
||||
return JSON.stringify(logo || {}).includes('YOUTUBE_PREMIUM_LOGO');
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
// Name of the channel that posted the video.
|
||||
@@ -350,7 +403,7 @@
|
||||
}
|
||||
|
||||
ns.youtube = {
|
||||
player, video, call, toggle, seek, seekTo, isPaused, currentTime, duration, title, channel, quality, setQuality, info,
|
||||
player, video, call, toggle, seek, seekTo, isPaused, currentTime, duration, title, channel, quality, setQuality, hasPremium, jumpAhead, jumpAheadOffered, info,
|
||||
speeds, speed, setSpeed, volume, setVolume, setMuted,
|
||||
captions, setCaptions, captionLines, audioTracks, setAudioTrack,
|
||||
loop, setLoop, autoplay, setAutoplay, playlistPosition, nextVideo, previousVideo, playAt,
|
||||
|
||||
@@ -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 || {});
|
||||
@@ -85,7 +85,9 @@
|
||||
// as uSize), d its distance
|
||||
// outside a rounded rectangle of corner radius uRadius (Inigo Quilez's sdRoundBox); the
|
||||
// last 4 mm inside the edge fade out (alpha, blended over what's behind without touching
|
||||
// the frame's alpha: see draw), and outside it's discarded.
|
||||
// the frame's alpha: see draw), and outside it's discarded. The video is read at this eye's
|
||||
// part of the frame across (uUvX: scale, offset; the whole frame, or a 3D side-by-side
|
||||
// frame's half).
|
||||
const VS = `
|
||||
attribute vec2 aCorner; // -0.5..0.5
|
||||
uniform mat4 uProj, uView, uModel; uniform vec2 uSize; uniform float uBend;
|
||||
@@ -101,14 +103,14 @@
|
||||
gl_Position = uProj * uView * uModel * vec4(p, 1.0);
|
||||
}`;
|
||||
const FS = `
|
||||
precision mediump float; uniform sampler2D uTex; uniform vec2 uScreen; uniform float uRadius; varying vec2 vUv;
|
||||
precision mediump float; uniform sampler2D uTex; uniform vec2 uScreen, uUvX; uniform float uRadius; varying vec2 vUv;
|
||||
void main() {
|
||||
vec2 p = (vUv - 0.5) * uScreen;
|
||||
vec2 q = abs(p) - uScreen * 0.5 + uRadius;
|
||||
float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - uRadius;
|
||||
float a = 1.0 - smoothstep(-0.004, 0.0, d);
|
||||
if (a <= 0.0) discard;
|
||||
gl_FragColor = vec4(texture2D(uTex, vUv).rgb, a);
|
||||
gl_FragColor = vec4(texture2D(uTex, vec2(vUv.x * uUvX.x + uUvX.y, vUv.y)).rgb, a);
|
||||
}`;
|
||||
|
||||
// The glow (WebGL 2 shaders, GLSL ES 3.00, for textureLod): the same strip, bigger (uSize,
|
||||
@@ -120,7 +122,8 @@
|
||||
// texture coordinate clamped to the picture) from the mipmap level about uDetail texels
|
||||
// wide (blurred; a level further up further out: chosen here rather than by the GPU, which
|
||||
// would pick the sharpest level where the clamped coordinate doesn't change), fading out to
|
||||
// nothing at uReach metres. Added to what's behind (light), at uStrength.
|
||||
// nothing at uReach metres. Added to what's behind (light), at uStrength. Read at this eye's
|
||||
// part of the frame across (uUvX), as FS.
|
||||
const GLOW_VS = `#version 300 es
|
||||
in vec2 aCorner;
|
||||
uniform mat4 uProj, uView, uModel; uniform vec2 uSize; uniform float uBend;
|
||||
@@ -137,7 +140,7 @@
|
||||
}`;
|
||||
const GLOW_FS = `#version 300 es
|
||||
precision mediump float;
|
||||
uniform sampler2D uTex; uniform vec2 uGlow, uScreen; uniform float uRadius, uReach, uDetail, uStrength;
|
||||
uniform sampler2D uTex; uniform vec2 uGlow, uScreen, uUvX; uniform float uRadius, uReach, uDetail, uStrength;
|
||||
in vec2 vUv;
|
||||
out vec4 outColor;
|
||||
void main() {
|
||||
@@ -149,6 +152,7 @@
|
||||
float a = uStrength * (1.0 - t) * (1.0 - t);
|
||||
if (a <= 0.002) discard;
|
||||
vec2 uv = clamp(p / uScreen + 0.5, 0.0, 1.0);
|
||||
uv.x = uv.x * uUvX.x + uUvX.y;
|
||||
float lod = log2(max(1.0, float(textureSize(uTex, 0).x) / uDetail)) + t * 1.5;
|
||||
outColor = vec4(textureLod(uTex, uv, lod).rgb, a);
|
||||
}`;
|
||||
@@ -201,18 +205,20 @@
|
||||
return new Float32Array(out);
|
||||
}
|
||||
|
||||
// Build the cinema screen renderer. Returns { upload, draw, place, dispose, interaction,
|
||||
// Build the cinema screen renderer for format `fmt` ('flat', or 'flatsbs': 3D side by side,
|
||||
// each eye its half of the frame). Returns { upload, draw, place, dispose, interaction,
|
||||
// pauseAnchor, distance, setDistance, setCurve }: the same drawing interface as the video
|
||||
// sphere, plus `interaction` (the laser-grabbable handles), `pauseAnchor` (where the pause
|
||||
// symbol goes) and the Settings controls.
|
||||
function createCinemaScreen(gl) {
|
||||
function createCinemaScreen(gl, fmt = 'flat') {
|
||||
const spans = ns.formats.eyeSpans(fmt); // which part of the frame each eye sees (3D side by side: its half)
|
||||
const prog = G.createProgram(gl, VS, FS, 'cinema screen');
|
||||
const hprog = G.createProgram(gl, VS, HANDLE_FS, 'cinema handles');
|
||||
const isGL2 = typeof WebGL2RenderingContext !== 'undefined' && gl instanceof WebGL2RenderingContext;
|
||||
const gprog = isGL2 ? G.createProgram(gl, GLOW_VS, GLOW_FS, 'cinema glow') : null; // (no glow on WebGL 1)
|
||||
const U = G.uniforms(gl, prog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', tex: 'uTex', screen: 'uScreen', radius: 'uRadius' });
|
||||
const U = G.uniforms(gl, prog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', tex: 'uTex', screen: 'uScreen', radius: 'uRadius', uvX: 'uUvX' });
|
||||
const GU = gprog && G.uniforms(gl, gprog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', tex: 'uTex', glow: 'uGlow',
|
||||
screen: 'uScreen', radius: 'uRadius', reach: 'uReach', detail: 'uDetail', strength: 'uStrength' });
|
||||
screen: 'uScreen', radius: 'uRadius', reach: 'uReach', detail: 'uDetail', strength: 'uStrength', uvX: 'uUvX' });
|
||||
const HU = G.uniforms(gl, hprog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', shape: 'uShape', arc: 'uArc', color: 'uColor', alpha: 'uAlpha' });
|
||||
const vaos = G.vertexArrays(gl);
|
||||
const corners = new Float32Array([-0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5]);
|
||||
@@ -237,6 +243,12 @@
|
||||
const prefs = loadPrefs();
|
||||
let width = clampWidth(prefs.width || SETTINGS.width);
|
||||
let aspect = 16 / 9;
|
||||
// The screen's shape from the frame's (width / height): 3D side by side, one eye's picture:
|
||||
// half the frame when it's two full pictures (wider than 2.5:1, e.g. 32:9), else the frame
|
||||
// itself (half side by side: each eye squeezed into half of a 16:9 frame, shown at 16:9).
|
||||
const eyeAspect = frame => (ns.formats.isSbs(fmt) && frame > 2.5 ? frame / 2 : frame);
|
||||
// This eye's part of the frame across ([scale, offset]; a headset that names no eye: the left).
|
||||
const eyeSpan = view => (view.eye === 'right' ? spans.right : spans.left);
|
||||
let model = M.translation(0, 0, -SETTINGS.distance); // centre + orientation (+Z faces you)
|
||||
let dist = clampDistance(prefs.distance || SETTINGS.distance);
|
||||
let curve = curvePrefs(); // { on, amount }
|
||||
@@ -322,7 +334,7 @@
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);
|
||||
mipped = G.videoMipmaps(gl, ns.vrTokens.glass.on || glow.on);
|
||||
if (video.videoWidth && video.videoHeight) aspect = video.videoWidth / video.videoHeight;
|
||||
if (video.videoWidth && video.videoHeight) aspect = eyeAspect(video.videoWidth / video.videoHeight);
|
||||
}
|
||||
|
||||
// What's behind the panels, for the glass look (vr-panel/panel-pass.js): the video's
|
||||
@@ -336,7 +348,7 @@
|
||||
if (!mipped) return null;
|
||||
const g = gprog && glow.on && glow.strength > 0
|
||||
? { strength: glow.strength, reach: height() * glow.reach, radius: width * SETTINGS.cornerRadius } : null;
|
||||
return { kind: 'screen', tex, centre: centre(), right: axis(0), up: axis(1), normal: axis(2), size: [width, height()], bend: bend(), glow: g };
|
||||
return { kind: 'screen', tex, centre: centre(), right: axis(0), up: axis(1), normal: axis(2), size: [width, height()], bend: bend(), glow: g, uvX: spans };
|
||||
}
|
||||
|
||||
// The handles: { id, frame (a point on the screen's centre line, see edgeFrame), x, y
|
||||
@@ -409,6 +421,7 @@
|
||||
gl.uniform1f(GU.reach, reach);
|
||||
gl.uniform1f(GU.detail, SETTINGS.glowDetail);
|
||||
gl.uniform1f(GU.strength, glow.strength);
|
||||
gl.uniform2fv(GU.uvX, eyeSpan(view));
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE, gl.ZERO, gl.ONE);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, SETTINGS.columns * 6);
|
||||
@@ -434,6 +447,7 @@
|
||||
gl.uniform2f(U.size, width, height());
|
||||
gl.uniform2f(U.screen, width, height());
|
||||
gl.uniform1f(U.radius, width * SETTINGS.cornerRadius);
|
||||
gl.uniform2fv(U.uvX, eyeSpan(view));
|
||||
gl.uniform1f(U.bend, b);
|
||||
gl.uniformMatrix4fv(U.proj, false, view.projectionMatrix);
|
||||
gl.uniformMatrix4fv(U.view, false, view.transform.inverse.matrix);
|
||||
@@ -567,7 +581,7 @@
|
||||
|
||||
// The right renderer for a format: cinema screen for flat videos, sphere for the rest.
|
||||
function createVideoView(gl, fmt) {
|
||||
return ns.formats.isFlat(fmt) ? createCinemaScreen(gl) : ns.videoSphere.createVideoSphere(gl, fmt);
|
||||
return ns.formats.isFlat(fmt) ? createCinemaScreen(gl, fmt) : ns.videoSphere.createVideoSphere(gl, fmt);
|
||||
}
|
||||
|
||||
// The global reset: forget the remembered size, placement and curve.
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
const { formats, gl: G } = ns;
|
||||
|
||||
// Vertex shader: passes the vertex position along as the view direction (vDir) for the
|
||||
// cubemap lookup, and applies uUvXform (scale xy, offset zw) to the texture coordinates;
|
||||
// it's [1,1,0,0] ("whole frame") now that 3D layouts are gone, but it's where per-eye
|
||||
// cropping would plug in.
|
||||
// cubemap lookup, and applies uUvXform (scale xy, offset zw) to the texture coordinates:
|
||||
// [1,1,0,0] for the whole frame, or one eye's half of a side-by-side 3D frame
|
||||
// (formats.eyeSpans).
|
||||
const VS = `
|
||||
attribute vec3 aPos; attribute vec2 aUv;
|
||||
uniform mat4 uProj, uView, uModel; uniform vec4 uUvXform;
|
||||
@@ -97,6 +97,7 @@
|
||||
const vao = vaos ? vaos.create() : null;
|
||||
if (vaos) vaos.bind(vao);
|
||||
const mesh = buildSphere(formats.longitudeSpan(fmt));
|
||||
const spans = formats.eyeSpans(fmt); // which part of the frame each eye sees
|
||||
const buffers = [
|
||||
G.floatAttribute(gl, prog, 'aPos', mesh.pos, 3),
|
||||
G.floatAttribute(gl, prog, 'aUv', mesh.uv, 2),
|
||||
@@ -117,13 +118,14 @@
|
||||
|
||||
// What's behind the panels, for the glass look (vr-panel/panel-pass.js): the video's
|
||||
// texture and how it's mapped ('sphere': equirectangular over `span` radians of
|
||||
// longitude; 'eac': the cubemap), turned by `yaw`. Builds the mipmaps at once if the
|
||||
// glass was just turned on. null while the glass is off.
|
||||
// longitude, `uvX` each eye's part of the frame across: { left, right }; 'eac': the
|
||||
// cubemap), turned by `yaw`. Builds the mipmaps at once if the glass was just turned on.
|
||||
// null while the glass is off.
|
||||
function backdrop() {
|
||||
if (!ns.vrTokens.glass.on) return null;
|
||||
if (!mipped) { gl.bindTexture(gl.TEXTURE_2D, tex); mipped = G.videoMipmaps(gl, true); }
|
||||
if (!mipped) return null;
|
||||
return { kind: formats.projectionMode(fmt) > 0.5 ? 'eac' : 'sphere', tex, span: formats.longitudeSpan(fmt), yaw: lastYaw };
|
||||
return { kind: formats.projectionMode(fmt) > 0.5 ? 'eac' : 'sphere', tex, span: formats.longitudeSpan(fmt), uvX: spans, yaw: lastYaw };
|
||||
}
|
||||
|
||||
// The video is "at infinity": only the head's rotation matters, not its position.
|
||||
@@ -147,7 +149,10 @@
|
||||
m[12] = m[13] = m[14] = 0; // drop the head's position, keep only its rotation
|
||||
gl.uniformMatrix4fv(U.proj, false, view.projectionMatrix);
|
||||
gl.uniformMatrix4fv(U.view, false, m);
|
||||
gl.uniform4fv(U.xf, [1, 1, 0, 0]); // whole frame for both eyes (YouTube sends one eye)
|
||||
// This eye's part of the frame (side by side: its half; a headset that names no eye
|
||||
// gets the left one).
|
||||
const [sx, ox] = view.eye === 'right' ? spans.right : spans.left;
|
||||
gl.uniform4fv(U.xf, [sx, 1, ox, 0]);
|
||||
gl.drawElements(gl.TRIANGLES, mesh.idx.length, gl.UNSIGNED_SHORT, 0);
|
||||
}
|
||||
if (vaos) vaos.bind(null);
|
||||
|
||||
@@ -241,6 +241,7 @@
|
||||
ns.vrKeyboardOptions.reset();
|
||||
ns.vrAppearanceOptions.reset();
|
||||
ns.vrSafeZone.reset();
|
||||
ns.vrSafeZoneOptions.reset();
|
||||
ns.vrFadeOptions.reset();
|
||||
ns.i18n.setChoice('auto');
|
||||
ns.storage.remove('passthrough'); // the popup's checkbox (next session)
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in new issue
Block a user