mirror of
https://github.com/FauxxYT/StreamXR.git
synced 2026-10-22 09:00:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a0c1f0d13 | ||
|
|
605972dfc4 | ||
|
|
8f5815dc1a | ||
|
|
3b0d26d082 | ||
|
|
200a8644c6 | ||
|
|
80934fcab4 | ||
|
|
e93b1a9a62 | ||
|
|
dec401c2be | ||
|
|
b49b3c79b3 | ||
|
|
5dfddfb746 |
No files matched your search
@@ -109,12 +109,12 @@ 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`),
|
||||
`channel.js`, `search.js`, `browse.js` (Explore and You), `upnext.js`, `settings.js`, `live-chat.js`),
|
||||
registered in `panels.js`; the controls panel's Captions and Save buttons are `extras.js`.
|
||||
- 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).
|
||||
|
||||
+25
-9
@@ -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")
|
||||
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,29 @@ 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(), 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`.
|
||||
- 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,7 +316,7 @@ 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) |
|
||||
@@ -312,7 +328,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:
|
||||
|
||||
+183
-6
@@ -6,13 +6,186 @@ 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.88.1
|
||||
|
||||
### 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 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 +199,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,47 @@ 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-v0881) · [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.88). Free and open source, forever. It already works every day; it's
|
||||
> still growing fast, and your feedback shapes what comes next.
|
||||
>
|
||||
> StreamXR is vibe coded by one guy (me!), and the only headset I've got is the Steam Frame.
|
||||
> Bug fixes, updates and support for more headsets and sites are coming, so please be
|
||||
> patient with me. 😭🙏
|
||||
|
||||
## ✨ What's new in v0.88.1
|
||||
|
||||
The latest release, in short (every version's details are in the [changelog](CHANGELOG.md)):
|
||||
|
||||
- 💬 **Live chat:** on a live stream, the chat opens on the right and stays up while you watch.
|
||||
Read it with emojis, badges, Super Chats and the pinned message, tap the stream's
|
||||
reactions (❤ 😄 🎉), and type messages on the VR keyboard, which now has emoji pages
|
||||
(with your channel's member emojis). A **Support on desktop** button points you to YouTube's
|
||||
window for Super Chats and memberships.
|
||||
- 🏆 **Top fans:** your **XP** in the chat, the ranked list of the stream's top fans, and
|
||||
the top three's #1 / #2 / #3 pills.
|
||||
- ⏪ **Chat replays:** on a finished stream that kept its chat, the chat plays along with the
|
||||
video. It pauses when you pause and follows when you jump back or ahead.
|
||||
- ✋ **Move the side panels:** grip the grab bar beside either side's toolbar and put that side
|
||||
where you like. Recentering keeps it there, unless you turn on Settings → Safe zone →
|
||||
**Recenter resets panels**.
|
||||
- 👆 **Hide everything in one pull** (suggested by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)): pull the trigger on empty space and the panels, toolbars
|
||||
and search bar go away at once. Pull again to bring them back.
|
||||
- ⏸️ **Settings → Auto-hide → Auto-hide while paused** (suggested by u/TehSuperFruitSnack too): pause and soak in a 180° / 360° scene
|
||||
with nothing in the way (off by default).
|
||||
- 🎚️ **Quality menu icons:** each quality shows its 4K / Full HD / HD (and 60 fps) icons, as the
|
||||
Quality button does.
|
||||
- 🖐️ **Clearer controller icons:** the grip shows a hand when it will grab something, and the
|
||||
left D-pad shows a panel icon for show / hide.
|
||||
- 🥽 **Meta Quest 2 reported working:** [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack) watches through SteamVR on a Quest 2, 8K videos included. Thank you! Other headsets: [tell me how yours does](#-adding-a-headset).
|
||||
- 🩹 **Fixes:** the chat now tries again on its own when YouTube's page hasn't loaded it yet;
|
||||
typing in the chat and the search bar no longer shows two text cursors; long dialog
|
||||
messages are no longer cut off.
|
||||
|
||||
## 💡 Why I made this
|
||||
|
||||
Two reasons:
|
||||
@@ -113,6 +142,11 @@ glassy toolbars on each side:
|
||||
- ➡️ **What to watch:** **Up Next**, **Explore** (your Home feed with all its topic chips,
|
||||
"Immersive videos" included, plus News) and **You** (Subscriptions, Your channel,
|
||||
History, Playlists, Watch later, Liked videos).
|
||||
- 💬 **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 +211,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 +261,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,7 +320,8 @@ 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
|
||||
@@ -433,6 +468,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)).
|
||||
@@ -500,8 +546,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
|
||||
|
||||
+55
-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
|
||||
|
||||
+37
-10
@@ -84,18 +84,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 +122,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+7
-2
@@ -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.88.1.
|
||||
|
||||
## Top priority: before the first release
|
||||
|
||||
@@ -80,7 +80,12 @@ Netflix first) once they allow protected video in desktop XR.
|
||||
- [ ] **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`).
|
||||
- [ ] **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`)
|
||||
|
||||
+116
@@ -37,6 +37,9 @@ backlogs in [features/](features/)):
|
||||
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).
|
||||
- `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.
|
||||
|
||||
@@ -153,6 +156,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.88.1",
|
||||
"description": "Watch streaming video in a SteamVR headset: 360° and VR180 videos around you, regular videos on a cinema screen. Supports YouTube.",
|
||||
"action": {
|
||||
"default_title": "StreamXR: open YouTube and click the VR button (the headset)",
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
// 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 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 +24,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 +40,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 +74,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;
|
||||
@@ -101,12 +110,13 @@
|
||||
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((name, k) => S.draw(g, name, r.x + ICON_LEFT + ICON_STEP * k + ICON_STEP / 2, r.y + r.h / 2, ICON, false));
|
||||
const lines = String(it.text).split('\n'), textW = r.w - textX - (lead ? 52 : 12);
|
||||
g.textAlign = 'left'; g.textBaseline = 'middle';
|
||||
g.font = T.font('labelLarge', 24);
|
||||
g.fillText(clip(lines[0], 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);
|
||||
});
|
||||
@@ -163,7 +173,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 +184,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)
|
||||
|
||||
@@ -90,13 +90,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 +110,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;
|
||||
}
|
||||
|
||||
@@ -56,12 +56,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'],
|
||||
|
||||
@@ -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? }] },
|
||||
// live: null | { atHead, behind (seconds), dvr } (a live stream; time
|
||||
// and duration then cover only the rewindable part),
|
||||
// speed, speeds: [rates], volume: { level, muted },
|
||||
@@ -128,7 +128,7 @@
|
||||
isOpen: () => isOpen(reg.id),
|
||||
closeMenu: () => { menu = null; },
|
||||
confirm: d => confirm(d),
|
||||
typeInto: on => typeInto(p, on),
|
||||
typeInto: (on, opts) => typeInto(p, on, opts),
|
||||
});
|
||||
panels.set(reg.id, p);
|
||||
}
|
||||
@@ -146,12 +146,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) {
|
||||
@@ -243,7 +249,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 +290,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 +385,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 +394,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 +431,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 +494,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 +542,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 +576,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;
|
||||
}
|
||||
@@ -663,6 +743,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 +773,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 +788,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 +910,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 +932,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 +1053,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,16 +1077,17 @@
|
||||
}
|
||||
|
||||
// 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 })),
|
||||
note: quality.levels.length ? null : tr('panel.noQualities', { site: site.name || tr('panel.theSite') }),
|
||||
};
|
||||
} else if (menu === 'format') {
|
||||
@@ -1061,7 +1165,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 +1175,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 +1252,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 +1264,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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,17 @@
|
||||
// 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
|
||||
// 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' }) }.
|
||||
@@ -47,7 +52,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)
|
||||
@@ -224,6 +224,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
|
||||
@@ -354,7 +358,7 @@
|
||||
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: { current: q.current, currentLabel: qualityName(q.current, q.labels), icons: qualityIcons(q.current, q.labels), levels: (q.levels || []).map(id => ({ id, label: qualityName(id, q.labels), icons: qualityIcons(id, q.labels) })) },
|
||||
speed: youtube.speed(), speeds: youtube.speeds(), volume: youtube.volume(), chapters: chapters(), heatmap: l ? [] : heatmap(), ad: ad(),
|
||||
nowPlaying: nowPlaying({ title, channel, details, playlist: pl, subscribeUi: subscribeUi() }),
|
||||
// For YouTube's panels:
|
||||
@@ -362,12 +366,13 @@
|
||||
recommendations: page.recommendations(),
|
||||
playback: { loop: youtube.loop(), autoplay: !!youtube.autoplay() }, audio: audioTracks(),
|
||||
extras: extras.state({ ad: ad() }),
|
||||
liveChat: chatFor(l), // a live stream's chat, or a finished one's chat replay (live-chat.js)
|
||||
};
|
||||
// No video playing (VR started from the Home page...): what the panels show instead of
|
||||
// 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 +382,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.
|
||||
@@ -121,6 +122,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.
|
||||
@@ -111,6 +112,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',
|
||||
|
||||
@@ -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 || {});
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user