mirror of
https://github.com/FauxxYT/StreamXR.git
synced 2026-10-22 19:00:53 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a0c1f0d13 |
No files matched your search
@@ -18,9 +18,6 @@ for the code rules.
|
||||
## Never
|
||||
- Add a library, a package manager, a build step or ES modules (plain scripts only).
|
||||
- Add a permission or a host to `manifest.json` without the maintainer agreeing to it first.
|
||||
- Bypass the site's own player (fetching its streams yourself, posing as another client) to
|
||||
get 3D, resolutions or anything else: StreamXR draws what the site's player plays. Point
|
||||
such requests to [docs/decisions/0002](docs/decisions/0002-site-player-only.md) and its risks.
|
||||
- Name a site (YouTube or any other) in `src/core/` or `src/streamxr/`: site code goes behind
|
||||
its provider in `src/sites/<site>/`.
|
||||
- Write on-screen words in the code: they go in the string tables (see "Where things go").
|
||||
@@ -118,7 +115,7 @@ for the code rules.
|
||||
are remembered as `streamxr.leftPanel` / `streamxr.rightPanel`. Their controllers are in
|
||||
`src/sites/youtube/page/provider/` (`description.js`, `comments-panel.js` (+ `comments.js`),
|
||||
`channel.js`, `search.js`, `browse.js` (Explore and You), `upnext.js`, `settings.js`, `live-chat.js`),
|
||||
registered in `panels.js`; the controls panel's Captions, Jump ahead and Save buttons are `extras.js`.
|
||||
registered in `panels.js`; the controls panel's Captions and Save buttons are `extras.js`.
|
||||
- New YouTube behavior goes in the provider or a panel controller; a style or the shell
|
||||
never gets a YouTube field (add a generic option or slot instead).
|
||||
- Tunables: `vr-panel/config.js`, `controllers/config.js`, `SETTINGS` in `cinema-screen.js`,
|
||||
|
||||
+3
-10
@@ -156,7 +156,7 @@ src/sites/youtube/ YOUTUBE
|
||||
provider/
|
||||
provider.js the provider (interface at its top); registers YouTube in sites.js
|
||||
panels.js the panel registry (each panel's zone, style, size, controller)
|
||||
extras.js the controls panel's Captions and Save buttons and Jump ahead at the seek bar's end (the shell's "extras": left, right, seek), premiumMark() (the Diamond)
|
||||
extras.js the controls panel's Captions and Save buttons (the shell's "extras")
|
||||
strings-en.js the YouTube panels' words in English (strings-<lang>.js: other languages)
|
||||
cards.js, badges.js YouTube items -> generic cards; the ✓ / ♪ badges
|
||||
upnext.js, browse.js (Explore and You), search.js, channel.js, description.js, comments-panel.js, settings.js, live-chat.js
|
||||
@@ -286,18 +286,11 @@ YouTube's Live chat uses all three (`available` while a live stream with a chat
|
||||
bar shrinking and laid out again) and keeps a `stays` panel drawn and updated while the panels
|
||||
are hidden.
|
||||
|
||||
- `host`: `{ actions, style, perPage, open(), close(), isOpen(), closeMenu(), openMenu(control), panel(id), confirm(d), typeInto(on, opts) }`
|
||||
- `host`: `{ actions, style, perPage, open(), close(), isOpen(), closeMenu(), confirm(d), typeInto(on, opts) }`
|
||||
(`opts.emojis`: the panel's own emojis, `[{ text, image, label }]`, first on the keyboard's emoji pages);
|
||||
`confirm(d)` asks in the shell's dialog (`{ title, text (up to four lines), confirm, cancel
|
||||
(null: no cancel button, a dialog that only informs), danger }` -> a Promise
|
||||
of true / false); the site's `onControl(id, panel, shell)` gets the same as `shell.confirm`.
|
||||
- Menus on side panels: `host.openMenu(control)` (e.g. `'action:bell'`) opens a floating menu
|
||||
above that control, 32 dp in front, like the controls panel's (the style needs `rectOf(id)`;
|
||||
Profile has it). While it's up, `vr-panel.js` asks the controller's `menu(control)` each frame
|
||||
(`{ items: [{ id, text, on, disabled, icons }], note, stay }`, `null` closes it); a choice
|
||||
arrives as `{ type: 'menu', value: item id, menu: control }`. The menu's id is
|
||||
`'p:<panel>|<control>'` and its items `'pi:<index>'` (`panelMenu`). `host.panel(id)` is another
|
||||
panel's controller (the You panel opens a channel in the Channel panel with it).
|
||||
- Typing: a panel with a text field calls `host.typeInto(true)` to get the keys (and
|
||||
`false` to give them back). One panel types at a time: when another asks, the one that had
|
||||
the keys is sent `Escape` (as for a click on nothing) and should stop typing. While it's shown, each key reaches its controller as
|
||||
@@ -326,7 +319,7 @@ control's id there is `<panel id>|<style id>`.
|
||||
| **Info** (`info.js`) | Description, Comments, Live chat | header tabs; each tab one content kind: `text` (rich text, links), `paged` (rows in two columns: chapters), `rows` (scrolling, current row followed: transcript), `thread` (pills, count, rows with children and "show more": comments), `chat` (chat.js: a pinned banner, messages with inline pictures and coloured cards, following the newest or paused with a "jump to newest" button; optionally a message field with Send, reaction buttons and a Support button, an XP pill and a ranked Top fans list: live chat), `message` (optional loading indicator) |
|
||||
| **Catalog** (`catalog.js`) | Explore, You, Search | title, source tabs, filter pills (tabs and left-aligned pills slide when they don't fit: kit.js createStrip) or a note or a text field with its button (and a suggestions list dropping down from it), card grid, paging; trims its height to its cards |
|
||||
| **Queue** (`queue.js`) | Up Next | title or tabs, paging in the header, 3 x 2 cards, a "current" card |
|
||||
| **Profile** (`profile.js`) | Channel | banner, picture, name + badge, stats line, one action button (looks: primary / secondary / danger / busy) and an optional round icon button beside it (the bell), tabs (shrinking to fit beside the pills), pills, a search button (searching: a back button and a text field in place of the tabs), card grid, paging, or one featured card, or a scrolling body: a text `page` (paragraphs, then label / value facts), a `feed` of posts (picture, author, text, pictures, a card, a poll with result bars, buttons with icons and counts), or one opened `post` with a back button and its comments (thread.js) |
|
||||
| **Profile** (`profile.js`) | Channel | banner, picture, name + badge, stats line, one action button (looks: primary / secondary / danger / busy), tabs (shrinking to fit beside the pills), pills, a search button (searching: a back button and a text field in place of the tabs), card grid, paging, or one featured card, or a scrolling body: a text `page` (paragraphs, then label / value facts), a `feed` of posts (picture, author, text, pictures, a card, a poll with result bars, buttons with icons and counts), or one opened `post` with a back button and its comments (thread.js) |
|
||||
| **Options** (`options.js`) | Settings | tabs (with icons); up to 3 rows (name with its hint under it; at the right: a switch, a slider with a Default button and end captions, a ‹ value › stepper, or a button; faded) or a 4 x 4 choice grid with "More" paging |
|
||||
|
||||
Each style file's header lists its model and control ids. Every style returns
|
||||
|
||||
-146
@@ -6,152 +6,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions are
|
||||
`manifest.json` version. All of these were developed in October 2026. Technical notes for
|
||||
developers are in [ARCHITECTURE.md](ARCHITECTURE.md) and [docs/](docs/).
|
||||
|
||||
## 0.93.3
|
||||
|
||||
### Changed
|
||||
- The cinema screen's and 3D Cinema's icons are Material Symbols' **2d_2** and **3d_2** (the
|
||||
plain "2D" / "3D" lettering).
|
||||
|
||||
## 0.93.2
|
||||
|
||||
### Changed
|
||||
- **The Format button** always says **Format**; its icon shows the chosen format (the format's
|
||||
name is in the menu).
|
||||
- The cinema screen's icon is Material Symbols' **2d** (3D Cinema keeps `3d`).
|
||||
- The Format menu names Cinema screen 3D side by side "Cinema 3D / side by side" again.
|
||||
|
||||
## 0.93.1
|
||||
|
||||
### Changed
|
||||
- **The Format menu** shows each format's icon on the left of its row (as the Quality menu
|
||||
does), the same icons as the Format button.
|
||||
- Cinema screen 3D side by side is called **3D Cinema** on the Format button (the longer name
|
||||
was too tight).
|
||||
|
||||
## 0.93.0
|
||||
|
||||
### Added
|
||||
- **Cinema screen 3D side by side:** a new format for regular (not VR180) 3D videos that put
|
||||
both eyes in one frame: the cinema screen gives each eye its half, in 3D, with the `3d` icon
|
||||
on the Format button (180° 3D side by side keeps its own). The screen takes one eye's shape:
|
||||
a 16:9 frame with each eye squeezed into half ("half side by side", the usual upload) shows
|
||||
at 16:9, a frame of two full pictures (wider than 2.5:1) at half its width. The glow and the
|
||||
glass behind the panels show each eye its own half too. Picked by hand in the Format menu.
|
||||
|
||||
## 0.92.3
|
||||
|
||||
### Fixed
|
||||
- **Jump ahead** now follows YouTube's own Jump ahead pill: the button at the seek bar's end
|
||||
shows only while YouTube offers it (so not on videos without it, live streams or their
|
||||
replays), and clicking it clicks YouTube's pill, which jumps (the keyboard shortcut it sent
|
||||
before did nothing from VR). Greyed out while paused.
|
||||
|
||||
## 0.92.2
|
||||
|
||||
### Fixed
|
||||
- **Videos didn't open from the Channel panel or History** while YouTube's page was on a
|
||||
channel page (VR started there, nothing playing yet): YouTube ignored the in-page
|
||||
navigation there. It's now sent from inside the page, which works on channel pages, Home
|
||||
and the watch page alike.
|
||||
- **Floating menus:** the ticked row covered the pointing ring of the row next to it; the
|
||||
rings are drawn after all the rows now.
|
||||
- **You → Channels sort pills** still didn't show: the sort choices sit deeper in YouTube's
|
||||
answer than the reader looked; it searches all the way down now.
|
||||
|
||||
## 0.92.1
|
||||
|
||||
### Changed
|
||||
- **Jump ahead** moved to the end of the seek bar, where Skip ad shows during ads (the
|
||||
maintainer's choice): a button with the Diamond, "Jump ahead" and fast-forward; the seek bar
|
||||
shortens for it. It's hidden on live streams and greyed out while paused. (A generic slot:
|
||||
a site's `extras.seek` button, panel-ui.js.)
|
||||
|
||||
## 0.92.0
|
||||
|
||||
### Added
|
||||
- **Jump ahead** (YouTube Premium) on the controls panel, right after +10 s: fast-forward over
|
||||
the **Diamond**. It jumps to the point most viewers skip to, as YouTube's own Jump ahead
|
||||
does (it sends YouTube's desktop shortcut, Ctrl + →, to the player). Usable while a video
|
||||
plays; not on live streams or during an ad. Without Premium it's greyed out with the purple
|
||||
Diamond. On videos YouTube doesn't offer Jump ahead for (Shorts, music, movies...) it does
|
||||
nothing.
|
||||
|
||||
### Changed
|
||||
- The controls panel's buttons narrow a little when the site adds more buttons than fit the
|
||||
row, so it keeps its margins (one more site button on the right, Jump ahead, made it so).
|
||||
|
||||
## 0.91.0
|
||||
|
||||
### Added
|
||||
- **1080p Premium** in the Quality menu (YouTube Premium's enhanced-bitrate 1080p, on videos
|
||||
uploaded at 1080p): its own row just above the plain 1080p, marked with the **Diamond** in
|
||||
YouTube red. Without Premium the row shows a purple Diamond and is greyed out (as YouTube's
|
||||
own menu upsells it). While it plays, the Quality button shows Full HD with the Diamond.
|
||||
- For other sites (ARCHITECTURE.md): an icon can carry its own colour (`{ name, color,
|
||||
filled }`: symbols.js `drawIcon`), in menu rows and on the Quality button; a quality level
|
||||
can be `disabled`.
|
||||
|
||||
## 0.90.3
|
||||
|
||||
### Fixed
|
||||
- **You → Channels:** the sort pills (Most relevant, New activity, A-Z) and the section flags
|
||||
(e.g. **Purchased**) never showed: they're read now from where YouTube's page really keeps
|
||||
them (the sort choices as list items that reload the list, the sections as titled shelves).
|
||||
|
||||
## 0.90.2
|
||||
|
||||
### Removed
|
||||
- **The Posts pill in You → Subscriptions:** desktop YouTube's Subscriptions feed doesn't mix
|
||||
community posts in (checked signed in), so it always came up empty. Posts are still on each
|
||||
channel's Posts tab.
|
||||
|
||||
## 0.90.1
|
||||
|
||||
### Added
|
||||
- **Channels sort pills:** You → Channels offers YouTube's own sort choices for the list (Most
|
||||
relevant, New activity, A-Z), the one it's sorted by chosen. Channels YouTube lists in a
|
||||
section of their own (e.g. **Purchased**, a channel you're a member of) carry that
|
||||
section's name as a flag.
|
||||
|
||||
### Fixed
|
||||
- **3D side by side:** the glass panels showed the left eye's picture behind them in both
|
||||
eyes; each eye now sees its own half behind the glass too.
|
||||
- **Floating menus** (the bell's, Format, Quality...) cut off the pointing ring round their
|
||||
first and last rows.
|
||||
- The Channels list could stop after its first page when YouTube splits it into sections.
|
||||
|
||||
## 0.90.0
|
||||
|
||||
### Added
|
||||
- **Your channels** (You → **Channels**, signed in): the channels you subscribe to, A to Z, with
|
||||
their round pictures, handle · subscribers and their notification setting. Click one to
|
||||
open it in the Channel panel.
|
||||
- **The notification bell** on the Channel panel: once you're subscribed, a round bell button
|
||||
beside Subscribed opens a small menu with YouTube's choices (All, Personalized, None), the
|
||||
current one ticked; its icon shows which is on. The tick moves only when YouTube confirms.
|
||||
- **Posts from your subscriptions:** You → Subscriptions has two pills, **Videos** (the feed)
|
||||
and **Posts** (the community posts mixed into it). A post that shares a video plays it;
|
||||
any other opens its channel's Posts tab.
|
||||
- For other sites' panels (ARCHITECTURE.md): side panels can open a floating menu from one of
|
||||
their controls (`host.openMenu(control)`, the controller's `menu(control)`, choices as
|
||||
`{ type: 'menu' }` events) and reach another panel's controller (`host.panel(id)`); the
|
||||
Profile style has a round icon button beside its action button (`iconAction`) and
|
||||
`rectOf(id)`; a card can show a round picture (`fit: 'round'`). New icons: `diamond`,
|
||||
`fast_forward`, `notifications_off`.
|
||||
|
||||
## 0.89.0
|
||||
|
||||
### Added
|
||||
- **3D side-by-side videos:** a new format, **180° 3D side by side**, in the Format menu (and
|
||||
the video page's format picker). Some VR180 videos reach the browser with both eyes in one
|
||||
frame (left eye's picture on the left), which used to be stretched over the half sphere as
|
||||
one picture; this format gives each eye its half, in 3D. YouTube's data doesn't mark these
|
||||
videos, so pick it by hand when a VR180 video shows two pictures side by side. (Most 3D
|
||||
videos reach desktop browsers as one eye only and still play flat:
|
||||
[decisions/0002](docs/decisions/0002-site-player-only.md).)
|
||||
- For other renderers: the glass backdrop's `uvX` option (the part of the frame a sphere
|
||||
backdrop covers, across), `formats.eyeSpans(fmt)`.
|
||||
|
||||
## 0.88.1
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -7,35 +7,43 @@ taking your headset off. 🎬✨
|
||||
**Works with:**<br> 🖥️ Windows · 🌐 Chrome · 🎮 SteamVR · 🥽 Valve Steam Frame · 🥽 Meta Quest 2 (reported by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)) · ([full requirements](#-what-you-need))<br>
|
||||
**Jump to:** [Get started](#-get-started-in-a-minute) ·
|
||||
[Quick troubleshooting](#-quick-troubleshooting) · [Controls](#-controls) ·
|
||||
[Your privacy](#-your-privacy) · [What's new](#-whats-new-in-v0933) · [Roadmap](#-roadmap) · [How you can help](#-how-you-can-help)
|
||||
[Your privacy](#-your-privacy) · [What's new](#-whats-new-in-v0881) · [Roadmap](#-roadmap) · [How you can help](#-how-you-can-help)
|
||||
|
||||
> **Early access** (version 0.93). Free and open source, forever. It already works every day; it's
|
||||
> **Early access** (version 0.88). Free and open source, forever. It already works every day; it's
|
||||
> still growing fast, and your feedback shapes what comes next.
|
||||
>
|
||||
> StreamXR is vibe coded by one guy (me!), and the only headset I've got is the Steam Frame.
|
||||
> Bug fixes, updates and support for more headsets and sites are coming, so please be
|
||||
> patient with me. 😭🙏
|
||||
|
||||
## ✨ What's new in v0.93.3
|
||||
## ✨ What's new in v0.88.1
|
||||
|
||||
The latest release, in short (every version's details are in the [changelog](CHANGELOG.md)):
|
||||
|
||||
- 🕶️ **3D side-by-side videos:** some 3D videos reach your browser with both eyes in one frame.
|
||||
Pick **180° 3D side by side** (VR180) or **Cinema 3D side by side** (regular 3D videos on the
|
||||
cinema screen) in the Format menu, and each eye gets its own half, in real 3D. Thanks to
|
||||
[u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack) for spotting them!
|
||||
- 💎 **YouTube Premium in VR:** **1080p Premium** (the enhanced bitrate) in the Quality menu, and
|
||||
**Jump ahead** at the end of the seek bar whenever YouTube offers it, both marked with the
|
||||
Diamond.
|
||||
- 👥 **Your channels:** You → **Channels** lists the channels you subscribe to, sorted your way
|
||||
(Most relevant, New activity, A-Z). Click one to open it in the Channel panel.
|
||||
- 🔔 **The notification bell:** on a channel you subscribe to, choose All, Personalized or None
|
||||
right from the Channel panel.
|
||||
- 🎚️ **A tidier Format menu:** every format with its own icon, and the Format button shows the
|
||||
one you picked.
|
||||
- 🩹 **Fixes:** videos open from the Channel panel and History even when VR started on a
|
||||
channel page; floating menus no longer clip the pointing ring; the Channels list loads all
|
||||
the way down.
|
||||
- 💬 **Live chat:** on a live stream, the chat opens on the right and stays up while you watch.
|
||||
Read it with emojis, badges, Super Chats and the pinned message, tap the stream's
|
||||
reactions (❤ 😄 🎉), and type messages on the VR keyboard, which now has emoji pages
|
||||
(with your channel's member emojis). A **Support on desktop** button points you to YouTube's
|
||||
window for Super Chats and memberships.
|
||||
- 🏆 **Top fans:** your **XP** in the chat, the ranked list of the stream's top fans, and
|
||||
the top three's #1 / #2 / #3 pills.
|
||||
- ⏪ **Chat replays:** on a finished stream that kept its chat, the chat plays along with the
|
||||
video. It pauses when you pause and follows when you jump back or ahead.
|
||||
- ✋ **Move the side panels:** grip the grab bar beside either side's toolbar and put that side
|
||||
where you like. Recentering keeps it there, unless you turn on Settings → Safe zone →
|
||||
**Recenter resets panels**.
|
||||
- 👆 **Hide everything in one pull** (suggested by [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack)): pull the trigger on empty space and the panels, toolbars
|
||||
and search bar go away at once. Pull again to bring them back.
|
||||
- ⏸️ **Settings → Auto-hide → Auto-hide while paused** (suggested by u/TehSuperFruitSnack too): pause and soak in a 180° / 360° scene
|
||||
with nothing in the way (off by default).
|
||||
- 🎚️ **Quality menu icons:** each quality shows its 4K / Full HD / HD (and 60 fps) icons, as the
|
||||
Quality button does.
|
||||
- 🖐️ **Clearer controller icons:** the grip shows a hand when it will grab something, and the
|
||||
left D-pad shows a panel icon for show / hide.
|
||||
- 🥽 **Meta Quest 2 reported working:** [u/TehSuperFruitSnack](https://www.reddit.com/user/TehSuperFruitSnack) watches through SteamVR on a Quest 2, 8K videos included. Thank you! Other headsets: [tell me how yours does](#-adding-a-headset).
|
||||
- 🩹 **Fixes:** the chat now tries again on its own when YouTube's page hasn't loaded it yet;
|
||||
typing in the chat and the search bar no longer shows two text cursors; long dialog
|
||||
messages are no longer cut off.
|
||||
|
||||
## 💡 Why I made this
|
||||
|
||||
@@ -103,8 +111,7 @@ more likely they are to open the door. **If and when they do, I'll make it work
|
||||
## 🌟 What you get
|
||||
### 🎥 Every kind of video, the way it was meant to be seen
|
||||
- **360° and VR180** wrap around you (YouTube's cubemap "mesh" format included); the format
|
||||
is detected automatically and can be changed any time. **3D side-by-side** videos play in
|
||||
real 3D, as VR180 or on the cinema screen (pick it in the Format menu).
|
||||
is detected automatically and can be changed any time.
|
||||
- **Regular videos** play on a **cinema screen** you can **curve**, **resize**, **move** and
|
||||
**push away**, with **rounded corners** and an optional **ambient glow** that spills the
|
||||
picture's colors around it, like a TV with backlighting. 🌈
|
||||
@@ -119,8 +126,7 @@ more likely they are to open the door. **If and when they do, I'll make it work
|
||||
- Title, views, date and the channel, with **Subscribe**, **Like** and **Dislike**.
|
||||
- A **seek bar** with chapter marks, a **preview frame**, YouTube's **Most Replayed** curve
|
||||
and how much has **loaded**.
|
||||
- **Quality** (8K, 4K, 60 fps, and **1080p Premium** 💎 with Premium), **speed**, **volume**,
|
||||
**format**, **recenter** and exit; **Jump ahead** 💎 at the seek bar's end when YouTube offers it.
|
||||
- **Quality** (8K, 4K, 60 fps...), **speed**, **volume**, **format**, **recenter** and exit.
|
||||
- **💬 Captions** in any language the video has, drawn right in VR.
|
||||
- **🔖 Save** to Watch later or any of your playlists.
|
||||
- Playlists get **Previous / Next** with a preview card; live streams get a **LIVE** label
|
||||
@@ -134,8 +140,7 @@ glassy toolbars on each side:
|
||||
and **Channel** (videos, Shorts, live streams, playlists, posts, About, Subscribe).
|
||||
- 🔎 **Search** above the video, with suggestions as you type on the **VR keyboard**.
|
||||
- ➡️ **What to watch:** **Up Next**, **Explore** (your Home feed with all its topic chips,
|
||||
"Immersive videos" included, plus News) and **You** (Subscriptions, your Channels with their
|
||||
notification bells, Your channel,
|
||||
"Immersive videos" included, plus News) and **You** (Subscriptions, Your channel,
|
||||
History, Playlists, Watch later, Liked videos).
|
||||
- 💬 **Live chat** on live streams (and **chat replays** on finished ones, playing along with the
|
||||
video): it opens by itself and stays up while you watch. Read it with emojis, badges, Super
|
||||
@@ -323,10 +328,8 @@ what bugs you most):
|
||||
CC BY, CC BY-SA, CC BY-NC-SA or CC0), like the Steam Frame models' (CC BY-NC-SA 4.0).
|
||||
|
||||
**🎬 Video**
|
||||
- **Most 3D (stereo) videos play flat.** On desktop, YouTube sends only one eye's picture for
|
||||
most 3D videos (and caps some VR180 videos at 4K), while the Meta Quest app gets both eyes.
|
||||
StreamXR shows what YouTube's own player plays and won't bypass it: [why](docs/decisions/0002-site-player-only.md).
|
||||
The 3D videos that do arrive with both eyes side by side play in 3D (Format menu).
|
||||
- **No 3D (stereo) videos.** On desktop, YouTube only sends one eye's picture, so 3D videos
|
||||
play flat.
|
||||
- **Shorts are very tall** on the cinema screen: they keep their vertical shape at the
|
||||
screen's full width. Honestly, I left this in because I think it's funny, but I'll fix it
|
||||
eventually lol 😂
|
||||
@@ -396,28 +399,18 @@ which is often all that's needed. 📋
|
||||
No dates, just what I'll work on after release, in this order:
|
||||
|
||||
1. 🐛 **Immediate bug fixes:** whatever the first wave of users runs into.
|
||||
2. 📋 **Add to queue:** line up videos to watch next from any panel, without leaving VR.
|
||||
3. 🧭 **Explore page tabs:** Live, Gaming, Music, Podcasts and the rest of the "Coming Soon"
|
||||
2. 🏪 **Chrome Web Store release:** install StreamXR in one click and get updates
|
||||
automatically, no more downloading new versions by hand. Still free, of course.
|
||||
3. 💎 **YouTube Premium features:** Premium quality levels, Add to queue and more.
|
||||
4. 🧭 **Explore page tabs:** Live, Gaming, Music, Podcasts and the rest of the "Coming Soon"
|
||||
tabs.
|
||||
4. ✅ **Live Chat:** done (v0.88.1): read, react and type in a live stream's chat.
|
||||
5. ✅ **YouTube Premium:** done (v0.93.3): 1080p Premium and Jump ahead, marked with the Diamond.
|
||||
6. 💬 **Commenting in-headset:** write comments and replies with the VR keyboard (typing in a live
|
||||
chat is already in).
|
||||
7. 🏪 **Chrome Web Store release, StreamXR 1.0:** once the YouTube features are all in, install
|
||||
StreamXR in one click and get updates automatically, no more downloading new versions by
|
||||
hand. Still free, of course.
|
||||
8. 🟣 **Twitch.tv support:** the first site after YouTube.
|
||||
9. 🪼 **Jellyfin** and 🟠 **Plex support:** your own media server's movies and shows in VR, on the
|
||||
cinema screen. Your own files aren't copy-protected, so they will be able to play.
|
||||
10. 🎵 **Music streaming**, starting with **Spotify:** listen while you're in VR, with the player's
|
||||
controls, album art and queue around you.
|
||||
11. 🌐 **Barebones mode (experimental):** any video on any website on the cinema screen, with the
|
||||
basic controls (play, seek, volume, format), for sites without full support. Some sites
|
||||
won't allow it.
|
||||
12. 🔐 **Groundwork for YouTube Movies, Netflix, HBO Max, Disney+ and other protected videos:** the
|
||||
infrastructure to play them in VR, ready for the day they allow it
|
||||
([why they don't yet](#-what-about-netflix-max-and-the-others)).
|
||||
13. ✨ **Whatever else I can think of** (and whatever you ask for!).
|
||||
5. 💬 **Live Chat:** See the chat of a Live Stream while watching
|
||||
6. 💬 **Commenting and Live Chat in-headset:** write comments, replies, and type in a live chat with the VR keyboard.
|
||||
7. 🟣 **Twitch.tv support:** the first site after YouTube.
|
||||
8. 🔐 **Groundwork for YouTube Movies, Netflix, HBO Max, Disney+ and other protected videos:** the
|
||||
infrastructure to play them in VR, ready for the day they allow it
|
||||
([why they don't yet](#-what-about-netflix-max-and-the-others)).
|
||||
9. ✨ **Whatever else I can think of** (and whatever you ask for!).
|
||||
|
||||
### ⏳ The "as soon as it's possible" roadmap\*
|
||||
|
||||
@@ -534,7 +527,7 @@ A reusable VR **shell** in two parts, the **core** (`src/core/`: the VR interfac
|
||||
and plugs in through its **provider** (a middle layer), its panels (built on five standard
|
||||
styles) and its page buttons. No build step and no ES modules: plain scripts loaded in
|
||||
order by `manifest.json` (one vendored library, Google's Material Color Utilities, in
|
||||
`third_party/`). Most 3D videos reach desktop as one eye only, and StreamXR never bypasses the site's player to get more ([decision 0002](docs/decisions/0002-site-player-only.md)).
|
||||
`third_party/`). There are no 3D formats: on desktop YouTube sends one eye only.
|
||||
|
||||
| Read | For |
|
||||
|---|---|
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# 0002: Play only what the site's own player plays
|
||||
|
||||
- **Status:** accepted (the maintainer, 2026-10-17)
|
||||
- **Prompted by:** a tester's report (u/TehSuperFruitSnack on Reddit) that the Meta Quest app
|
||||
shows VR180 videos in 3D and up to 8K, while StreamXR shows one eye and 4K at most.
|
||||
|
||||
## Context
|
||||
|
||||
StreamXR draws the `<video>` element that the site's own player is already playing; it never
|
||||
fetches a video stream itself. On desktop that has limits (checked in the browser, see
|
||||
[youtube.md](../youtube.md) "Formats and VR180"):
|
||||
|
||||
- **Most 3D VR180 videos reach desktop as one eye only.** YouTube removes the right eye
|
||||
before the stream reaches the browser (a "LEFT / RIGHT" test video shows LEFT in both
|
||||
eyes). Some videos reach desktop with both eyes side by side; those StreamXR can show in
|
||||
3D (a format to pick by hand).
|
||||
- **Some VR180 videos top out at 4K on desktop** (the player offers 2160s at most), while
|
||||
others reach 8K.
|
||||
|
||||
The Quest app most likely identifies itself to YouTube as a VR client and gets the full
|
||||
stereo, higher-resolution versions. Matching it would mean **bypassing YouTube's player**:
|
||||
asking YouTube for the streams as if StreamXR were that app, and playing them in a player of
|
||||
our own.
|
||||
|
||||
## Decision
|
||||
|
||||
**StreamXR doesn't bypass the site's player.** It shows what the site's own player plays.
|
||||
If a site starts sending stereo or higher resolutions to browsers, StreamXR shows them. This
|
||||
holds for every site, not just YouTube. Suggestions to bypass the player (from users,
|
||||
contributors or forks) get pointed here.
|
||||
|
||||
## Why: the risks of bypassing the player
|
||||
|
||||
1. **Rules and distribution.**
|
||||
- YouTube's Terms of Service don't allow accessing videos other than through YouTube's
|
||||
own player and interfaces, or getting around its ads.
|
||||
- The Chrome Web Store rejects extensions that break a site's terms or enable
|
||||
unauthorized downloading or streaming; that would block the planned store release.
|
||||
- It invites legal trouble: in 2020 the RIAA had youtube-dl taken down from GitHub,
|
||||
arguing that working around YouTube's scrambled stream addresses ("rolling cipher") was
|
||||
circumvention. GitHub reinstated it, but the claim was made.
|
||||
2. **It breaks constantly.**
|
||||
- Posing as another app means copying its client name, version and request format,
|
||||
which YouTube changes often and blocks when they're not genuine.
|
||||
- Stream addresses are scrambled by code that changes with player updates, and YouTube
|
||||
increasingly requires proof-of-origin tokens (BotGuard) that are hard to produce
|
||||
outside its own player.
|
||||
- Tools like yt-dlp need fixes every few weeks for this; StreamXR would join that race.
|
||||
3. **Rebuilding the site's player.**
|
||||
- StreamXR would need its own player feeding the video and audio streams to the browser
|
||||
(Media Source Extensions) and choosing qualities.
|
||||
- Captions, audio tracks, quality switching, live streams, chat replays tied to the video
|
||||
time, ads and Skip ad, and the resume position all come from YouTube's player today;
|
||||
each would need rebuilding or would break.
|
||||
4. **Ads and creators.** Playing the streams directly skips YouTube's ads: the thing YouTube
|
||||
fights hardest, and revenue the creators people are watching depend on.
|
||||
5. **Users' accounts and privacy.**
|
||||
- Requests sent with the user's signed-in session while posing as another app are the
|
||||
kind of traffic YouTube flags; at worst, an account gets warnings, extra verification
|
||||
or restrictions.
|
||||
- It needs a host permission for YouTube's video servers, against the project's
|
||||
"minimal permissions" goal, with a broader warning for users.
|
||||
|
||||
## Consequences
|
||||
|
||||
- 3D on one-eye VR180 videos, and more than 4K on those YouTube caps on desktop, are
|
||||
documented as desktop YouTube limits (the README's "Known issues"), not open work.
|
||||
- What desktop YouTube does send is used fully: 8K where offered, and (planned) a side-by-side 3D
|
||||
format for videos that arrive with both eyes.
|
||||
- A fork may decide otherwise; it then takes on the risks above itself.
|
||||
@@ -276,20 +276,3 @@ even where they look like something to "fix".
|
||||
- The lasers are smoothed against hand tremor (pointing, clicks and grabbing alike), with
|
||||
fast moves passing through at once. The user sets how much in Settings → Laser
|
||||
(Steadiness, Off to Steadiest; the middle is the default, which felt right in testing).
|
||||
- **Subscriptions (the maintainer, 2026-10-17):** your subscribed channels are a **Channels** tab
|
||||
in the You panel, right after Subscriptions (round channel pictures, a click opens the
|
||||
channel in the Channel panel). A channel's **notification bell** (All / Personalized / None)
|
||||
is a bell button beside Subscribe on the Channel panel, opening a small floating menu like
|
||||
the controls panel's menus. The Subscriptions feed was to show **community posts** (desktop's
|
||||
feed has none: 0.90.2), but not its
|
||||
shelves (the Shorts row, "Most relevant"), as on Home.
|
||||
- **Premium (the maintainer, 2026-10-17):** from the audit in `features/video-page.md`: 1080p
|
||||
Premium in the Quality menu and Jump ahead, with the Diamond; queueing isn't Premium on
|
||||
desktop and is its own item.
|
||||
- **Jump ahead sits at the seek bar's end** (the maintainer, 2026-10-17, 0.92.1), where Skip ad
|
||||
shows during ads, not in the button row: the Diamond, "Jump ahead", fast-forward.
|
||||
- **3D side by side on the cinema screen** (the maintainer, 2026-10-17, 0.93.0): its own
|
||||
format with Material Symbols' `3d_2` icon (0.93.3); 180° 3D side by side keeps `3d_rotation`.
|
||||
- **The Format button says "Format"** (the maintainer, 2026-10-17, 0.93.2), whatever's chosen;
|
||||
its icon shows the format, and the Format menu shows every format's icon and name. The
|
||||
cinema screen's icon is `2d_2`, 3D Cinema's `3d_2` (0.93.3).
|
||||
@@ -40,19 +40,15 @@ the same shape.
|
||||
- VR idea: a Subscribe button on the Channel panel (`channel-page.md`) and on
|
||||
Up Next cards. Also lets the controls panel's Subscribe button unsubscribe, which it
|
||||
can't do today (YouTube's confirm menu blocks it).
|
||||
2. [ ] **Posts in the feed**: deferred (0.90.2, see "Deferred"). The shelves (a Shorts row,
|
||||
"Most relevant") are deferred too, as on Home.
|
||||
2. [ ] **Shelves and posts in the feed** (small to medium)
|
||||
- Code: `p.shelves` (Shorts row, "Most relevant"), `p.posts`
|
||||
- VR idea: as on Home. Posts need the post cards from the channel Posts tab.
|
||||
3. [x] ~~**Manage list: your subscribed channels**~~ DONE (v0.90.0, works in the headset; sort pills 0.92.2: You →
|
||||
Channels, round pictures, a click opens the Channel panel)
|
||||
3. [ ] **Manage list: your subscribed channels** (medium)
|
||||
- Code: `channels()` -> [{ id, title, handle, subscribers, thumb, subscribed,
|
||||
notifications }]
|
||||
- VR idea: a list of channel rows (avatar, name, counts); clicking one opens its Channel
|
||||
panel. Needs a scrolling list rather than the card grid.
|
||||
4. [x] ~~**Notification bell per channel**~~ DONE (v0.90.0, works in the headset: the bell button
|
||||
beside Subscribed on the Channel panel, a floating menu of YouTube's choices; the Channels
|
||||
list shows each setting)
|
||||
4. [ ] **Notification bell per channel** (medium)
|
||||
- Code: `setNotifications(option)` with one of a channel's `notifications.options`
|
||||
(All / Personalized / None); `[]` means YouTube offers no bell for that channel.
|
||||
- VR idea: a bell button on each Manage row and on the Channel panel, opening a small
|
||||
@@ -82,12 +78,3 @@ needed for now (Subscribe from the controls panel and the Channel panel works in
|
||||
3. **Watch later / Liked videos / your playlists**: their browse ids are in
|
||||
`youtube-home.js` (`feed('watchLater')`, `feed('liked')`) but untested signed in;
|
||||
saving a video to a playlist needs another request.
|
||||
|
||||
## Deferred (the maintainer, 2026-10-17)
|
||||
|
||||
- **Shelves in the Subscriptions feed** (a Shorts row, YouTube's "Most relevant"): for the same
|
||||
reason as Home's shelves (`home-page.md`, "Deferred"): Shorts fit VR poorly. `p.shelves` is
|
||||
still read.
|
||||
- **Posts in the Subscriptions feed** (0.90.2): desktop's feed doesn't mix community posts in
|
||||
(a probe signed in found no post data in `FEsubscriptions`), so the Posts pill built in
|
||||
0.90.0 was always empty and was taken out. Posts stay on each channel's Posts tab.
|
||||
@@ -51,25 +51,10 @@ published; what matters from them is in this file and docs/youtube.md).
|
||||
`provider/cards.js`); check Browse and Search results show movies the same way.
|
||||
- [ ] **Premium features** (the maintainer, after 0.78.4): YouTube Premium's extras in VR, marked with
|
||||
Material Symbols' **Diamond** icon: **purple** when you don't have Premium (an upsell, like
|
||||
YouTube's own), **YouTube Red** when you do. Find out first how the page says whether you
|
||||
have Premium (a probe signed in with Premium).
|
||||
- **Audit** (2026-10-17, from YouTube's help article "YouTube Premium benefits", answer
|
||||
6308116, computer and Android versions):
|
||||
|
||||
| Benefit | On desktop web? | In StreamXR |
|
||||
|---|---|---|
|
||||
| **1080p Premium** (enhanced bitrate, 1080p uploads only) | yes, an extra Quality entry | **Done (0.91.0, works in the headset):** its own Quality row with the Diamond (red with Premium; purple and greyed out without) |
|
||||
| **Jump ahead** (to the point most viewers skip to) | yes (double-click, Ctrl + →) | **Done (0.92.3, works in the headset):** a button at the seek bar's end (Skip ad's spot) with the Diamond, shown while YouTube's own pill is up; clicking it clicks that pill |
|
||||
| Ad-free videos | yes | Already: no ads means nothing to skip |
|
||||
| Continue watching across devices | yes (account) | Already: YouTube's player resumes |
|
||||
| Queue | for everyone on desktop (Premium only on phones) | Not Premium here: its own item (Add to queue, below) |
|
||||
| Downloads (offline) | desktop has a Download button | Deferred: watching in VR needs the PC online anyway |
|
||||
| Background play, picture-in-picture, Smart downloads, On-the-go mode, Auto speed | phones only | Out |
|
||||
| Higher-quality audio (256 kbps music) | apps and TVs only | Out |
|
||||
| YouTube Music app, Premium badges, Co-Watch with Meet, Afterparties, smart devices | other apps | Out |
|
||||
| Early access to new features (experiments) | varies | Out: they come and go |
|
||||
- [ ] **Add to queue** (desktop: for everyone, not Premium): queue a video from a card and
|
||||
play the queue in order. Find out first how the page's queue can be driven.
|
||||
YouTube's own), **YouTube Red** when you do. To include: the Premium quality levels
|
||||
("1080p Premium" in the Quality menu: check they show and work), **Add to queue**, and
|
||||
Premium's other features (to list from the watch page: e.g. Jump ahead, downloads where
|
||||
they make sense in VR). Find out first how the page says whether you have Premium.
|
||||
|
||||
## Medium: new panels with scrolling or long text
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ permissions, Firefox) are in [ARCHITECTURE.md](../ARCHITECTURE.md#known-limits).
|
||||
- The Channel header's subscribed state can be wrong for a channel whose Home tab features
|
||||
other channels; it's only used when the watch page doesn't say.
|
||||
- Text matching assumes **English** YouTube: some readers recognise YouTube's own words
|
||||
(the "All" chip, "Joined", an ad's "1 of 2", the Jump ahead pill). This is about what YouTube writes;
|
||||
(the "All" chip, "Joined", an ad's "1 of 2"). This is about what YouTube writes;
|
||||
StreamXR's own words follow the interface language (Settings → Language).
|
||||
- Replies to replies (YouTube's newer nested comment threads) aren't shown; one level is.
|
||||
|
||||
|
||||
+14
-62
@@ -2,7 +2,7 @@
|
||||
|
||||
One list of what's still open, gathered from the feature backlogs (`docs/features/`) and
|
||||
`docs/known-issues.md`, which hold the details. Tick or remove items as they're done.
|
||||
Last updated for 0.93.3.
|
||||
Last updated for 0.88.1.
|
||||
|
||||
## Top priority: before the first release
|
||||
|
||||
@@ -49,34 +49,16 @@ until another headset or two has been tried and the unverified items are cleared
|
||||
## The public roadmap (README, after release, in this order)
|
||||
|
||||
1. Immediate bug fixes (what the first users run into).
|
||||
2. Add to queue (free on desktop, not Premium; how the page's queue can be driven is to probe
|
||||
first: `features/video-page.md`).
|
||||
3. Explore page tabs (the "Coming Soon" ones).
|
||||
4. Done: Live chat (0.79.0 to 0.88.1), YouTube Premium (1080p Premium 0.91.0, Jump ahead 0.92.3).
|
||||
2. Chrome Web Store release (one-click install, automatic updates, still free; needs the
|
||||
store's privacy questions answered: the README's "Your privacy" section covers them).
|
||||
3. YouTube Premium features (below: the Diamond icon, Premium qualities, Add to queue ...).
|
||||
4. Explore page tabs (the "Coming Soon" ones).
|
||||
5. Commenting in-headset (writing comments and replies with the VR keyboard).
|
||||
6. Chrome Web Store release as **1.0** (the maintainer, 2026-10-17: once the YouTube features are
|
||||
all in; one-click install, automatic updates, still free; needs the store's privacy
|
||||
questions answered: the README's "Your privacy" section covers them).
|
||||
7. Twitch.tv support (the first site after YouTube: a new provider, `src/sites/twitch/`; reuses
|
||||
the live chat panel).
|
||||
8. Jellyfin and Plex support (new providers, `src/sites/jellyfin/`, `src/sites/plex/`: no DRM on
|
||||
your own media). Self-hosted servers live at any address, so each needs host access to the
|
||||
user's server: an optional permission asked when it's turned on (the maintainer agrees on
|
||||
any permission first: AGENTS.md).
|
||||
9. Music streaming, Spotify first (its audio is DRM-protected, but sound plays as on the page;
|
||||
VR shows the player's controls, art and queue, no video to draw). Needs open.spotify.com's
|
||||
host permission (to agree on first).
|
||||
10. Barebones mode, **experimental** (the maintainer, 2026-10-17: kept out of 1.0 and after the
|
||||
full providers, since how well it works depends on each site and first impressions
|
||||
matter): any `<video>` on any site on the cinema screen with basic controls (play, seek,
|
||||
volume, format), a generic provider for sites without one. Likely shape: `activeTab` +
|
||||
`scripting` (it runs only on a page where you click StreamXR's button; nothing added to
|
||||
the install warning), to agree on first. Unprotected video only (DRM: see 11), and videos
|
||||
served cross-origin without CORS can't be drawn in WebGL, so many sites will refuse.
|
||||
11. Groundwork for DRM-protected videos (YouTube Movies, HBO Max and Netflix first, then Disney+, Prime Video): providers and
|
||||
player ready for when browsers and the services allow protected video in WebXR (e.g. media
|
||||
layers); the README asks users to tell the services they want desktop XR support.
|
||||
12. Whatever else comes up.
|
||||
6. Twitch.tv support (the first site after YouTube: a new provider, `src/sites/twitch/`).
|
||||
7. Groundwork for DRM-protected videos (YouTube Movies, HBO Max and Netflix first, then Disney+, Prime Video): providers and
|
||||
player ready for when browsers and the services allow protected video in WebXR (e.g. media
|
||||
layers); the README asks users to tell the services they want desktop XR support.
|
||||
8. Whatever else comes up.
|
||||
|
||||
**As soon as it's possible** (README's second roadmap, ahead of everything above once the support exists):
|
||||
YouTube Movies (copy-protected: once YouTube allows protected video in desktop XR; showing
|
||||
@@ -86,48 +68,18 @@ controllers instead of "Oculus Touch"), native Steam Frame support (StreamXR in
|
||||
Chromium browser, no PC, once that browser supports WebXR), streaming services (HBO Max and
|
||||
Netflix first) once they allow protected video in desktop XR.
|
||||
|
||||
## Next: from u/TehSuperFruitSnack's Reddit feedback (after 0.88.1)
|
||||
|
||||
1. [ ] **Cubemap seams** (small): thin lines along the cube's edges on 360° cubemap ("MESH")
|
||||
videos; regular (equirectangular) 360° videos don't have them. Likely cause: the glass look
|
||||
(on by default) builds mipmaps of the video (`gl-utils.js` `videoMipmaps`), and the
|
||||
sphere's cubemap lookup (`video-sphere.js` `eacUv`) jumps across the texture where two faces
|
||||
meet, so the GPU picks the smallest mipmap along those lines. Check first: the seams should
|
||||
go away with the glass off (Settings → Appearance). Fix: in cubemap mode, sample the video
|
||||
at full resolution (an explicit level of detail, or gradients from the smooth view direction).
|
||||
- Test: [SheiKra 8K 360° roller coaster](https://www.youtube.com/watch?v=tR8ZtyhSDYw)
|
||||
(cubemap, 8K); for comparison, with no seams:
|
||||
[a regular 360° video](https://www.youtube.com/watch?v=Q7UwGaom0XA).
|
||||
2. [x] **3D side-by-side videos** (0.89.0, works in the headset; the glass per eye fixed in 0.90.1): some VR180 videos reach desktop with both eyes in
|
||||
one frame (LEFT | RIGHT), and StreamXR stretches the whole frame over the half sphere. Add a
|
||||
side-by-side format to the Format menu that gives each eye its half (`video-sphere.js`
|
||||
`uUvXform`, the per-eye hook); picked by hand, since YouTube's data doesn't mark these
|
||||
videos (auto-detecting by comparing the frame's halves could come later).
|
||||
- Test: [VR180 Left/Right](https://www.youtube.com/watch?v=ccU0imlNp5Q) (side by side:
|
||||
LEFT in the left eye, RIGHT in the right). For comparison, one eye only, which stays flat:
|
||||
[Left Right Test in VR 180](https://youtu.be/kle719aQvsc?t=8).
|
||||
- Not planned: 3D for videos that reach desktop as one eye, and more than 4K where desktop
|
||||
YouTube caps it (e.g. [this VR180 tour](https://www.youtube.com/watch?v=KJ-_LUrGhYE)):
|
||||
desktop YouTube limits ([decisions/0002](decisions/0002-site-player-only.md)).
|
||||
3. [x] **3D side by side on the cinema screen** (0.93.0, works in the headset): the same for
|
||||
regular (not VR180) 3D videos, each eye its half on the cinema screen.
|
||||
- Test: [Demo 3D[SBS] (HD : 1080P)](https://www.youtube.com/watch?v=u0hmMt5znX0) (half side
|
||||
by side: a 16:9 frame, each eye squeezed into half).
|
||||
|
||||
## Features to build (easiest first)
|
||||
|
||||
- [ ] **Movies in Up Next**: the price / rating in place of views and date, a yellow Movie tag
|
||||
(`features/video-page.md`).
|
||||
- [x] **Premium features** (audited 2026-10-17, `features/video-page.md`): **1080p Premium** in the
|
||||
Quality menu and **Jump ahead**, marked with the Diamond (purple without Premium, YouTube Red
|
||||
with it). 1080p Premium (0.91.0) and Jump ahead (0.92.3) work in the headset. **Add to queue** is its own item
|
||||
(not Premium on desktop).
|
||||
- [ ] **Premium features**: the Premium quality levels, Add to queue and the rest, marked with the
|
||||
Diamond icon (purple without Premium, YouTube Red with it) (`features/video-page.md`).
|
||||
- [ ] **Explore's "Coming Soon" tabs**: Live, Gaming, Music, Hype, Courses, Podcasts,
|
||||
Memberships (readers exist); Shorts, Movies & TV, Learning need more first
|
||||
(`features/home-page.md`).
|
||||
- [ ] **News**: its own tabs (Top stories, Sports ...) and the Sports feed.
|
||||
- [x] **Subscriptions** (0.90.0 to 0.92.2, work in the headset): the Channels tab with its sort pills, the bell on the Channel
|
||||
panel, posts in the feed; the feed's shelves deferred (`features/subscriptions-page.md`).
|
||||
- [ ] **Subscriptions**: shelves and posts in the feed, the list of your channels, the bell per
|
||||
channel (`features/subscriptions-page.md`).
|
||||
- [ ] **Live chat**: reading (0.79.1) and reactions (0.80.0) work in the headset; typing, the
|
||||
keyboard's emoji pages and Support on desktop built in 0.81.0, Top fans (XP, the list, rank
|
||||
pills) in 0.82.0 and chat replays in 0.87.0 (all work in the headset). Other viewers' floating
|
||||
|
||||
+4
-40
@@ -19,9 +19,6 @@ backlogs in [features/](features/)):
|
||||
- `youtube-player.js` (`ns.youtube`): everything on the `#movie_player` API.
|
||||
- `youtube-page.js` (`ns.youtubePage`): Up Next (merged data + DOM scraping, load more,
|
||||
playlists), the controls panel's details, Subscribe / Like / Dislike, in-page navigation.
|
||||
In-page navigation is a `yt-navigate` event with a watch endpoint, sent from
|
||||
`ytd-page-manager`: sent from `ytd-app` itself, YouTube ignored it on a channel page (it
|
||||
worked on the watch page and Home), checked 2026-10-17.
|
||||
- `youtube-content.js` (`ns.youtubeContent`): metadata, description, owner, chapters,
|
||||
heatmap, end screen, cards, playlist, comments / replies, transcript, search.
|
||||
- `youtube-channel.js` (`ns.youtubeChannel`): any channel by id (`currentChannelId()` for the
|
||||
@@ -39,15 +36,7 @@ backlogs in [features/](features/)):
|
||||
Manage list `subscribed` is always true; one channel had only a stub `subscriptionButton`
|
||||
and no bell), subscribe / unsubscribe / `setNotifications` (they report ok only when
|
||||
YouTube's answer shows the new state). Filter chips: none on desktop for the tested
|
||||
account (the documented ones seem app-only). **No community posts** in the desktop feed
|
||||
(signed in, 2026-10-17: no post data in `FEsubscriptions` at all). The Manage page
|
||||
(`FEchannels`, seen in the signed-in page's `ytInitialData`, 2026-10-17): the list is split
|
||||
into **titled shelves** (`shelfRenderer` inside the item section: "Purchased" for channels
|
||||
you're a member of, then "Subscribed"), and its **sort dropdown** (Most relevant, New
|
||||
activity, A-Z) is a set of `listItemViewModel`s with `entityKey`
|
||||
`channel_list_page_sort_chip_entity:<name>`, each tap updating a `chipEntity` (the
|
||||
dropdown's label) and sending a browse `continuationCommand` that reloads the list in that
|
||||
order (`sortsOf`, `channelsOn`).
|
||||
account (the documented ones seem app-only).
|
||||
- `youtube-chat.js` (`ns.youtubeChat`): a live stream's chat ("Live chat" below): `info()`
|
||||
(its views), `open(token)` (a view's chat page), `poll(next)`, and the chat items as plain
|
||||
messages and changes (add, remove, banner).
|
||||
@@ -107,35 +96,10 @@ The formats are in `src/streamxr/shared/formats.js`; YouTube's detection is the
|
||||
from `getAvailableQualityData()` (`qualityLabel`, e.g. "1080p60", or "2160s60" for a 360° /
|
||||
VR180 video: "s" for spherical) where the player has it
|
||||
(`youtube-player.js` `quality().labels`); with Premium it also lists "1080p Premium",
|
||||
kept apart as its own level (0.91.0). Seen signed in with Premium (2026-10-17): the entry
|
||||
has `formatId` "721", `quality` "hd1080", `paygatedQualityDetails.paygatedIndicatorText`
|
||||
"Enhanced bitrate", `isPlayable` true, beside the plain 1080p (`formatId` "399"); it's
|
||||
chosen with `setPlaybackQualityRange(q, q, formatId)` and `getPlaybackQualityLabel()` names
|
||||
the one playing. **Whether you have Premium:** the top bar's logo,
|
||||
`ytInitialData.topbar.desktopTopbarRenderer.logo.topbarLogoRenderer.iconImage.iconType`
|
||||
is `YOUTUBE_PREMIUM_LOGO` (`youtube-player.js` `hasPremium`). **Jump ahead:** the page has
|
||||
no data for it, and the player's `prefetchJumpAhead()` returns undefined and moves nothing.
|
||||
YouTube shows a **Jump ahead pill** in the player's "timely actions" overlay at the moments
|
||||
it applies (`.ytp-timely-actions-overlay ytw-timely-action-view-model button`, its text
|
||||
"Jump ahead"), and a script's `click()` on it jumps (17 s -> 124 s, 2026-10-17), so
|
||||
`youtube-player.js` `jumpAhead()` clicks it and `jumpAheadOffered()` tells whether it's up.
|
||||
A script-sent Ctrl + → keydown on `#movie_player` jumped from the console but did nothing
|
||||
from VR.
|
||||
skipped in favour of the plain entry. Unverified in the headset (see known-issues.md).
|
||||
|
||||
- Desktop YouTube sends **one eye only** for most 3D videos (checked 2026-10-17 with frames
|
||||
from the page's `<video>`): the "Left Right Test in VR 180" (`kle719aQvsc`, 1920×2160) and
|
||||
a 3D VR180 tour (`KJ-_LUrGhYE`, 3840×2160, 2160s at most on desktop) hold the left eye
|
||||
only. **Some arrive side by side** (both eyes in one frame): "VR180 Left/Right"
|
||||
(`ccU0imlNp5Q`, 3840×2160, LEFT | RIGHT). Nothing in the player response tells them apart
|
||||
(all `MESH`, `vrConfig.partialSpherical`, no stereo field). The Quest app gets both eyes
|
||||
and higher resolutions, most likely as a VR client; StreamXR doesn't bypass the page's
|
||||
player to get them ([decisions/0002](decisions/0002-site-player-only.md)).
|
||||
- 8K reaches desktop where YouTube offers it, e.g. an 8K 360° video (`tR8ZtyhSDYw`):
|
||||
`4320s`, 7680×3840, AV1 (itag 571).
|
||||
- Formats: `flat` (cinema screen), `flatsbs` (the cinema screen in 3D side by side: 0.93.0;
|
||||
e.g. "Demo 3D[SBS]", `u0hmMt5znX0`, a RECTANGULAR 16:9 frame, each eye squeezed into half),
|
||||
`360mono`, `360eac`, `180mono`, `180sbs` (3D side by side,
|
||||
picked by hand: 0.89.0).
|
||||
- Desktop YouTube sends **one eye only**, even for 3D videos, so there are no 3D formats.
|
||||
- Formats: `flat` (cinema screen), `360mono`, `360eac`, `180mono`.
|
||||
- "MESH" 360° videos use an **equi-angular cubemap**, 3×2: top row left | front | right,
|
||||
bottom row down | back | up; down and up rotated 270° clockwise, back 90° (the same as
|
||||
ffmpeg's `eac`). Verified against a real frame.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "StreamXR",
|
||||
"version": "0.93.3",
|
||||
"version": "0.88.1",
|
||||
"description": "Watch streaming video in a SteamVR headset: 360° and VR180 videos around you, regular videos on a cinema screen. Supports YouTube.",
|
||||
"action": {
|
||||
"default_title": "StreamXR: open YouTube and click the VR button (the headset)",
|
||||
|
||||
@@ -38,14 +38,10 @@
|
||||
// drawn at the tint, so alpha / tint is 1 anywhere on the panel, less on its
|
||||
// antialiased edge, 0 outside it);
|
||||
// - behind(): the ray from the eye (uEye) through this point, into the backdrop:
|
||||
// uKind 1 'sphere' (equirectangular, uSpan radians of longitude, turned by uYaw;
|
||||
// its across coordinate then scaled and shifted by uUvX: (1, 0) for the whole
|
||||
// frame, (0.5, 0) / (0.5, 0.5) for the left / right eye's half of a side-by-side 3D
|
||||
// frame, set per eye),
|
||||
// uKind 1 'sphere' (equirectangular, uSpan radians of longitude, turned by uYaw),
|
||||
// uKind 2 'eac' (YouTube's cubemap, the same layout as video-sphere.js),
|
||||
// uKind 3 'screen' (the cinema screen: centre uScrC, axes uScrR / uScrU / uScrN
|
||||
// (toward you), size uScrSize, bent by uBend radians around a vertical circle;
|
||||
// sampled at this eye's part of the frame across, uUvX, as above);
|
||||
// (toward you), size uScrSize, bent by uBend radians around a vertical circle);
|
||||
// its texture coordinates, or "outside" (the dark void colour, uVoid);
|
||||
// - the blur: the backdrop texture with a mipmap bias (texture2D's third argument, so
|
||||
// a much smaller mipmap is read), at the centre and four points around it (uBlur
|
||||
@@ -63,7 +59,7 @@
|
||||
uniform sampler2D uTex, uBack;
|
||||
uniform float uGlass, uTint, uKind, uSpan, uYaw, uBend, uBlur, uAlpha;
|
||||
uniform vec3 uEye, uScrC, uScrR, uScrU, uScrN, uVoid;
|
||||
uniform vec2 uScrSize, uTexel, uUvX;
|
||||
uniform vec2 uScrSize, uTexel;
|
||||
uniform vec3 uGlow; // the cinema screen's glow: strength (0: none), reach (m), corner radius (m)
|
||||
varying vec2 vUv; varying vec3 vWorld;
|
||||
const float PI = 3.14159265;
|
||||
@@ -97,7 +93,7 @@
|
||||
vec3 l = vec3(c * d.x - s * d.z, d.y, s * d.x + c * d.z);
|
||||
if (uKind > 1.5) return vec3(eacUv(l), 1.0);
|
||||
float lon = atan(l.x, -l.z), pol = acos(clamp(l.y, -1.0, 1.0));
|
||||
return vec3((lon / uSpan + 0.5) * uUvX.x + uUvX.y, pol / PI, step(abs(lon), uSpan * 0.5));
|
||||
return vec3(lon / uSpan + 0.5, pol / PI, step(abs(lon), uSpan * 0.5));
|
||||
}
|
||||
// The cinema screen, in its own axes.
|
||||
vec3 e = eye - uScrC;
|
||||
@@ -136,16 +132,13 @@
|
||||
vec3 back = uVoid;
|
||||
if (uKind > 0.5) {
|
||||
vec3 b = behind(uEye, normalize(vWorld - uEye));
|
||||
// On the cinema screen, sample this eye's part of the frame across (uUvX; the picture's
|
||||
// own coordinates, b.xy, stay for the glow's shape).
|
||||
vec2 sb = uKind > 2.5 ? vec2(clamp(b.x, 0.0, 1.0) * uUvX.x + uUvX.y, b.y) : b.xy;
|
||||
if (b.z > 0.75) {
|
||||
const float BIAS = 5.0;
|
||||
vec3 s = texture2D(uBack, sb, BIAS).rgb * 0.4;
|
||||
s += texture2D(uBack, sb + vec2(uBlur, uBlur), BIAS).rgb * 0.15;
|
||||
s += texture2D(uBack, sb + vec2(-uBlur, uBlur), BIAS).rgb * 0.15;
|
||||
s += texture2D(uBack, sb + vec2(uBlur, -uBlur), BIAS).rgb * 0.15;
|
||||
s += texture2D(uBack, sb + vec2(-uBlur, -uBlur), BIAS).rgb * 0.15;
|
||||
vec3 s = texture2D(uBack, b.xy, BIAS).rgb * 0.4;
|
||||
s += texture2D(uBack, b.xy + vec2(uBlur, uBlur), BIAS).rgb * 0.15;
|
||||
s += texture2D(uBack, b.xy + vec2(-uBlur, uBlur), BIAS).rgb * 0.15;
|
||||
s += texture2D(uBack, b.xy + vec2(uBlur, -uBlur), BIAS).rgb * 0.15;
|
||||
s += texture2D(uBack, b.xy + vec2(-uBlur, -uBlur), BIAS).rgb * 0.15;
|
||||
back = s;
|
||||
} else if (b.z > 0.25 && uGlow.x > 0.0) {
|
||||
// Past the screen's edge: its glow (cinema-screen.js GLOW_FS) added to the dark, the
|
||||
@@ -156,7 +149,7 @@
|
||||
vec2 q = abs(p) - uScrSize * 0.5 + uGlow.z;
|
||||
float dist = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - uGlow.z;
|
||||
float k = clamp(dist / uGlow.y, 0.0, 1.0), a = uGlow.x * (1.0 - k) * (1.0 - k);
|
||||
if (a > 0.002) back += texture2D(uBack, sb, 6.0).rgb * a;
|
||||
if (a > 0.002) back += texture2D(uBack, b.xy, 6.0).rgb * a;
|
||||
}
|
||||
}
|
||||
float grain = fract(sin(dot(floor(vUv / uTexel), vec2(12.9898, 78.233))) * 43758.5453) - 0.5;
|
||||
@@ -186,7 +179,7 @@
|
||||
const prog = G.createProgram(gl, VS, FS, 'vr panel');
|
||||
const U = G.uniforms(gl, prog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', tex: 'uTex', back: 'uBack',
|
||||
alpha: 'uAlpha', glass: 'uGlass', tint: 'uTint', kind: 'uKind', span: 'uSpan', yaw: 'uYaw', bend: 'uBend', blur: 'uBlur', eye: 'uEye',
|
||||
scrC: 'uScrC', scrR: 'uScrR', scrU: 'uScrU', scrN: 'uScrN', voidC: 'uVoid', scrSize: 'uScrSize', texel: 'uTexel', glow: 'uGlow', uvX: 'uUvX' });
|
||||
scrC: 'uScrC', scrR: 'uScrR', scrU: 'uScrU', scrN: 'uScrN', voidC: 'uVoid', scrSize: 'uScrSize', texel: 'uTexel', glow: 'uGlow' });
|
||||
const vao = vaos.create(); vaos.bind(vao);
|
||||
// Two triangles making the square (6 corners, x/y pairs).
|
||||
const buf = G.floatAttribute(gl, prog, 'aCorner', new Float32Array([-0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5]), 2);
|
||||
@@ -227,9 +220,6 @@
|
||||
gl.uniform1f(U.blur, BLUR[backdrop.kind] || 0.01);
|
||||
gl.uniform1f(U.span, backdrop.span || Math.PI * 2);
|
||||
gl.uniform1f(U.yaw, backdrop.yaw || 0);
|
||||
// (uvX: one [scale, offset] for both eyes, or { left, right }: each eye its own part)
|
||||
const ux = backdrop.uvX, eyeX = !ux ? [1, 0] : Array.isArray(ux) ? ux : view.eye === 'right' ? ux.right : ux.left;
|
||||
gl.uniform2fv(U.uvX, eyeX);
|
||||
if (backdrop.kind === 'screen') {
|
||||
gl.uniform3fv(U.scrC, backdrop.centre); gl.uniform3fv(U.scrR, backdrop.right);
|
||||
gl.uniform3fv(U.scrU, backdrop.up); gl.uniform3fv(U.scrN, backdrop.normal);
|
||||
|
||||
@@ -40,11 +40,7 @@
|
||||
// "Skip on desktop" without the Skip Helper extension),
|
||||
// extras: { left: [...], right: [...] } (the site's own buttons, at the inner ends
|
||||
// of the outer parts of the button row: { id, icon, label (under the
|
||||
// icon), on (icon filled), disabled, icons (optional: one or two icons, e.g.
|
||||
// { name, color } ones, stacked in place of icon + label, like Quality's) },
|
||||
// and seek: null | { id, label, icon, mark (an icon spec on its left, e.g.
|
||||
// a coloured one), disabled } (one button at the seek bar's right end, where
|
||||
// Skip ad goes during ads; the bar shortens for it); ids 'x:<id>'),
|
||||
// icon), on (icon filled), disabled }; ids 'x:<id>'),
|
||||
// menuOpen: null | 'quality' | 'speed' | 'format' | 'volume' | 'x:<id>' (that button
|
||||
// is shown selected: its menu floats in front of the panel, popups-ui.js) }
|
||||
// hover/pressed: a control id or null. Fixed ids: seek, exit, quality, speed, back10,
|
||||
@@ -75,11 +71,9 @@
|
||||
};
|
||||
// The button row: one M3E button group, in three parts ([Exit, Quality, Speed, the site's
|
||||
// left extras] [-10 s, play / pause, +10 s] [the site's right extras, Volume, Format,
|
||||
// Recenter]), centred (room for one extra a side at full width; with more, the buttons
|
||||
// narrow to stay inside ROW_MARGIN). Buttons are BTN.w x BTN.h, play / pause is bigger;
|
||||
// GAP between buttons, SEP between the parts.
|
||||
// Recenter]), centred (room for one extra a side). Buttons are BTN.w x
|
||||
// BTN.h, play / pause is bigger; GAP between buttons, SEP between the parts.
|
||||
const BTN = { w: 88, h: 88, y: ROW_Y + 2 }, PLAY = { w: 132, h: 104, y: ROW_Y - 6 }, GAP = 8, SEP = 20;
|
||||
const ROW_MARGIN = 48; // the button row keeps this far from the panel's sides
|
||||
// In a playlist: Previous / Next video buttons at the two ends of the seek row, and the
|
||||
// seek bar shortened between them.
|
||||
const PLAYLIST = {
|
||||
@@ -97,7 +91,7 @@
|
||||
const tr = (key, vars) => ns.i18n.t(key, vars); // words in the interface language (i18n.js)
|
||||
|
||||
// Which icon a format gets on the Format button.
|
||||
const FORMAT_ICONS = { flat: '2d_2', flatsbs: '3d_2', '360mono': '360', '360eac': 'view_in_ar', '180mono': 'panorama_photosphere', '180sbs': '3d_rotation' };
|
||||
const FORMAT_ICONS = { flat: 'theaters', '360mono': '360', '360eac': 'view_in_ar', '180mono': 'panorama_photosphere' };
|
||||
// The speaker for a volume (0-100) / muted.
|
||||
const volumeIcon = v => (v.muted ? 'volume_off' : v.level <= 0 ? 'volume_mute' : v.level < 50 ? 'volume_down' : 'volume_up');
|
||||
|
||||
@@ -235,8 +229,8 @@
|
||||
// An icon over a small label (the button row's buttons), or the icon alone, centred.
|
||||
// One icon big, or two stacked (the first smaller and higher, e.g. 4K over 60 fps).
|
||||
function iconStack(icons) {
|
||||
if (icons.length < 2) { S.drawIcon(g, icons[0], 0, 0, 48, false); return; }
|
||||
S.drawIcon(g, icons[0], 0, -14, 38, false); S.drawIcon(g, icons[1], 0, 20, 30, false);
|
||||
if (icons.length < 2) { S.draw(g, icons[0], 0, 0, 48, false); return; }
|
||||
S.draw(g, icons[0], 0, -14, 38, false); S.draw(g, icons[1], 0, 20, 30, false);
|
||||
}
|
||||
function iconLabel(icon, text, filled = false) {
|
||||
if (text) { S.draw(g, icon, 0, -13, 36, filled); label(text, 'labelMedium', 23); }
|
||||
@@ -324,6 +318,9 @@
|
||||
// The button row (see BTN): each shows its icon and, under it, its current value.
|
||||
function drawTransport(state) {
|
||||
const v = state.volume || { level: 100, muted: false };
|
||||
// The format's short name, first line only (a two-line name would be too small; the
|
||||
// icon tells the formats apart).
|
||||
const fmtLabel = ((state.format && state.format.label) || '—').split('\n')[0];
|
||||
// The site's own buttons (state.extras): [id, icon, label, extra].
|
||||
const extras = side => ((state.extras && state.extras[side]) || []).map(x => [`x:${x.id}`, x.icon, x.label || '', x]);
|
||||
const items = [
|
||||
@@ -333,15 +330,10 @@
|
||||
['back10', 'replay_10', ''], ['play', state.paused ? 'play_arrow' : 'pause', ''], ['fwd10', 'forward_10', ''], null,
|
||||
...extras('right'),
|
||||
['volume', volumeIcon(v), v.muted ? tr('panel.muted') : `${Math.round(v.level)}%`],
|
||||
['format', formatIcon(state.format && state.format.id), tr('panel.format')], // (the icon tells the formats apart)
|
||||
['format', FORMAT_ICONS[state.format && state.format.id] || 'theaters', fmtLabel],
|
||||
['recenter', 'recenter', tr('panel.recenter')],
|
||||
];
|
||||
// Buttons narrow (from BTN.w) when a site's extra buttons would push the row past the
|
||||
// panel's side margins; heights stay.
|
||||
const n = items.filter(it => it && it[0] !== 'play').length, seps = items.filter(it => !it).length;
|
||||
const gaps = (items.length - seps - 1) * GAP + seps * (SEP - GAP);
|
||||
const bw = Math.min(BTN.w, Math.floor((W - ROW_MARGIN * 2 - PLAY.w - gaps) / Math.max(1, n)));
|
||||
const width = it => (it[0] === 'play' ? PLAY.w : bw);
|
||||
const width = it => (it[0] === 'play' ? PLAY.w : BTN.w);
|
||||
const total = items.reduce((s, it) => s + (it ? width(it) + GAP : SEP - GAP), -GAP);
|
||||
let x = (W - total) / 2;
|
||||
for (const it of items) {
|
||||
@@ -353,9 +345,8 @@
|
||||
} else {
|
||||
// the button whose menu is open shows selected (its menu floats in front: popups-ui.js);
|
||||
// an extra that's `on` (e.g. captions showing) has its icon filled
|
||||
// the quality as icons (its full name is in its menu), or a site button's own icons
|
||||
const qi = id === 'quality' ? state.quality && state.quality.icons : extra && extra.icons;
|
||||
button({ x, y: BTN.y, w: bw, h: BTN.h }, id, state, () => (qi && qi.length ? iconStack(qi) : iconLabel(icon, text, !!(extra && extra.on))),
|
||||
const qi = id === 'quality' && state.quality && state.quality.icons; // the quality as icons (its full name is in its menu)
|
||||
button({ x, y: BTN.y, w: BTN.w, h: BTN.h }, id, state, () => (qi && qi.length ? iconStack(qi) : iconLabel(icon, text, !!(extra && extra.on))),
|
||||
{ kind: state.menuOpen === id ? 'selected' : 'tonal', disabled: !!(extra && extra.disabled) });
|
||||
}
|
||||
x += width(it) + GAP;
|
||||
@@ -417,16 +408,6 @@
|
||||
regions = [];
|
||||
const queue = state.nowPlaying && state.nowPlaying.queue;
|
||||
seekRect = queue && !state.ad ? PLAYLIST.seek : FIXED.seek;
|
||||
// The site's seek-row button (state.extras.seek, e.g. Jump ahead), where Skip ad goes
|
||||
// during ads: at the seek bar's right end (before a playlist's next button), the bar
|
||||
// shortened to make room.
|
||||
const sa = !state.ad && state.extras && state.extras.seek;
|
||||
let saRect = null;
|
||||
if (sa) {
|
||||
const right = queue ? PLAYLIST.next.x - 16 : seekRect.x + seekRect.w;
|
||||
saRect = { x: right - FIXED.skipad.w, y: FIXED.skipad.y, w: FIXED.skipad.w, h: FIXED.skipad.h };
|
||||
seekRect = { ...seekRect, w: saRect.x - 24 - seekRect.x };
|
||||
}
|
||||
g.clearRect(0, 0, W, H);
|
||||
g.fillStyle = bodyFill();
|
||||
roundRect(g, 0, 0, W, BODY_H, T.shape.extraLarge); g.fill();
|
||||
@@ -480,7 +461,6 @@
|
||||
else {
|
||||
drawSeekBar(state, shown);
|
||||
if (queue) drawPlaylistButtons(queue, state);
|
||||
if (sa) drawSeekAction(sa, saRect, state);
|
||||
}
|
||||
|
||||
drawTransport(state); // the button row
|
||||
@@ -523,19 +503,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The site's seek-row button (id 'x:<id>', like its other extras), shaped like Skip ad:
|
||||
// its mark (an icon, e.g. the Diamond in its own colour) on the left, the label, its icon
|
||||
// on the right. Greyed out when disabled.
|
||||
function drawSeekAction(x, r, state) {
|
||||
button(r, `x:${x.id}`, state, () => {
|
||||
const left = x.mark ? 36 : 0;
|
||||
if (x.mark) S.drawIcon(g, x.mark, -r.w / 2 + 30, 0, 26, true);
|
||||
const t = ns.vrKit.fitFont(g, x.label || '', r.w - 84 - left, 24, '500 ');
|
||||
g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(t, -20 + left / 2, 1);
|
||||
if (x.icon) S.draw(g, x.icon, r.w / 2 - 34, 0, 30, true);
|
||||
}, { disabled: !!x.disabled });
|
||||
}
|
||||
|
||||
// A live stream, in place of the time (top right): a "● LIVE" chip in the brand colour at
|
||||
// the live moment. Behind it (rewound, paused, or while scrubbing) the chip turns tonal,
|
||||
// with how far behind in front ("−0:45"), and clicking it (id 'live') jumps back to the
|
||||
@@ -657,8 +624,5 @@
|
||||
titleMoving: () => titleMoving, animating: motion.moving };
|
||||
}
|
||||
|
||||
// The Format button's icon for a format id (also each row's icon in the Format menu: vr-panel.js).
|
||||
const formatIcon = id => FORMAT_ICONS[id] || '2d_2';
|
||||
|
||||
ns.vrPanelUI = { createPanelUI, formatTime: fmt, speedName, formatIcon };
|
||||
ns.vrPanelUI = { createPanelUI, formatTime: fmt, speedName };
|
||||
})(globalThis.__streamxr = globalThis.__streamxr || {});
|
||||
@@ -10,8 +10,7 @@
|
||||
// right above its button.
|
||||
// const menu = ns.vrPopupsUI.createMenu();
|
||||
// menu.draw(m, { hover, pressed }) m: { items: [{ id, text ("1080p" or "1080p\nPremium"),
|
||||
// on (the current one: ticked), disabled, icons (optional: Material Symbols names, or
|
||||
// { name, color } for one in its own colour (symbols.js drawIcon), drawn
|
||||
// on (the current one: ticked), disabled, icons (optional: Material Symbols names drawn
|
||||
// on the row's left, e.g. ['4k', '60fps']; the tick then moves to the right end) }],
|
||||
// note (a grey line above the items), scroll
|
||||
// (a long list scrolls: see drawList), key, width }
|
||||
@@ -97,11 +96,7 @@
|
||||
list = { x: card.x, y: card.y + PAD + noteH, w: listW, h: shownRows * rowH };
|
||||
maxScroll = m.scroll ? Math.max(0, items.length * rowH - list.h) : 0;
|
||||
scroll = Math.min(maxScroll, Math.max(0, scroll));
|
||||
// Clipped to the rows, plus the card's padding when nothing scrolls (so the hover ring
|
||||
// round the first and last rows, drawn just outside them, isn't cut).
|
||||
const bleed = maxScroll ? 0 : PAD;
|
||||
g.save(); g.beginPath(); g.rect(list.x, list.y - bleed, list.w, list.h + bleed * 2); g.clip();
|
||||
const rings = []; // [row, radius, amount] to ring after the rows
|
||||
g.save(); g.beginPath(); g.rect(list.x, list.y, list.w, list.h); g.clip();
|
||||
items.forEach((it, i) => {
|
||||
const r = { x: card.x + PAD, y: list.y + i * rowH - scroll, w: listW - PAD * 2 - (maxScroll ? 12 : 0), h: rowH };
|
||||
if (r.y + r.h < list.y || r.y > list.y + list.h) return; // scrolled out of view
|
||||
@@ -116,17 +111,15 @@
|
||||
const fg = it.on ? P.onSecondaryContainer : P.onSurface;
|
||||
g.fillStyle = fg;
|
||||
if (it.on) S.draw(g, 'check', lead ? r.x + r.w - 30 : r.x + 32, r.y + r.h / 2, 28, false);
|
||||
(it.icons || []).forEach((icon, k) => S.drawIcon(g, icon, r.x + ICON_LEFT + ICON_STEP * k + ICON_STEP / 2, r.y + r.h / 2, ICON, false));
|
||||
(it.icons || []).forEach((name, k) => S.draw(g, name, r.x + ICON_LEFT + ICON_STEP * k + ICON_STEP / 2, r.y + r.h / 2, ICON, false));
|
||||
const lines = String(it.text).split('\n'), textW = r.w - textX - (lead ? 52 : 12);
|
||||
g.textAlign = 'left'; g.textBaseline = 'middle';
|
||||
g.font = T.font('labelLarge', 24);
|
||||
g.fillText(clip(lines[0], textW), r.x + textX, r.y + r.h / 2 + (lines.length > 1 ? -11 : 1));
|
||||
if (lines.length > 1) { g.font = T.font('labelMedium', 17); g.fillStyle = it.on ? fg : P.onSurfaceVariant; g.fillText(clip(lines.slice(1).join(' '), textW), r.x + textX, r.y + r.h / 2 + 15); }
|
||||
g.globalAlpha = 1;
|
||||
if (hv > 0.01) rings.push([r, rad, hv]);
|
||||
hoverRing(g, r.x, r.y, r.w, r.h, rad, hv);
|
||||
});
|
||||
// The pointing rings last, so a neighbouring row (e.g. the ticked one's fill) can't cover one.
|
||||
for (const [r, rad, hv] of rings) hoverRing(g, r.x, r.y, r.w, r.h, rad, hv);
|
||||
g.restore();
|
||||
if (maxScroll) { // the scroll bar: where the view is in the list
|
||||
const th = Math.max(36, list.h * list.h / (list.h + maxScroll)), ty = list.y + (list.h - th) * scroll / maxScroll;
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
// current (outlined in blue: the item playing now),
|
||||
// progress (0-1: how much of it you've watched, a bar along the thumbnail's bottom in
|
||||
// the brand colour (the site's theme); 0 or missing: no bar),
|
||||
// fit ('contain': the whole picture, letterboxed, e.g. a product; 'round': a round picture
|
||||
// in the middle, e.g. a person or a channel (a letter until it loads); default:
|
||||
// cropped to fill),
|
||||
// fit ('contain': the whole picture, letterboxed, e.g. a product; default: cropped to fill),
|
||||
// inert (just to look at: not clickable, no hover highlight) }
|
||||
// Badges: a site registers its own (registerBadge); drawBadge(name) draws one if registered.
|
||||
(function (ns) {
|
||||
@@ -170,11 +168,7 @@
|
||||
// Thumbnail (a grey box with a play triangle until it loads)
|
||||
g.save(); roundRect(g, x, y, W_, TH, T.shape.medium); g.clip();
|
||||
const img = image(card.thumb);
|
||||
if (card.fit === 'round') { // a round picture (a person) in the middle of the box
|
||||
const s = Math.round(TH * 0.8);
|
||||
g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH);
|
||||
avatar(card.thumb, card.title, x + (W_ - s) / 2, y + (TH - s) / 2, s);
|
||||
} else if (img && card.fit === 'contain') { g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH); drawImageContain(img, x, y, W_, TH); }
|
||||
if (img && card.fit === 'contain') { g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH); drawImageContain(img, x, y, W_, TH); }
|
||||
else if (img) drawImageCover(img, x, y, W_, TH);
|
||||
else {
|
||||
g.fillStyle = TC.n17; g.fillRect(x, y, W_, TH);
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
// instead of the title while header is null),
|
||||
// action: { id, label, look: 'primary' | 'secondary' | 'danger' | 'busy' } | null
|
||||
// (one button on the right; 'busy' isn't clickable),
|
||||
// iconAction: { id, icon, look: 'primary' | 'secondary' | 'busy' } | null (a round
|
||||
// icon button just left of the action button, e.g. one that opens a menu),
|
||||
// tabs: [{ id, label }], tab, pills: [{ id, label }], pill,
|
||||
// search: bool (a search button at the tab row's end, 'search'),
|
||||
// field: null | a text field (grid.js: { id, text, placeholder, focused, button,
|
||||
@@ -43,9 +41,7 @@
|
||||
// 'clear:<id>' / 'submit:<id>' (searching), the thread's own (thread.js), 'text' (anywhere
|
||||
// else on a scrolling body: a trigger drag can start there).
|
||||
// Scrolling (a body): scrollAt(u, v), maxScroll(), shownScroll(), pxPerV.
|
||||
// Also: hitTest(u, v) -> { id } | null, has(id), rectOf(id) -> { x, y, w, h } (px) | null
|
||||
// (a control's place in the last draw, for a menu opening from it), imagesReady(),
|
||||
// perPage, canvas, aspect.
|
||||
// Also: hitTest(u, v) -> { id } | null, has(id), imagesReady(), perPage, canvas, aspect.
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
@@ -64,7 +60,6 @@
|
||||
// the same everywhere on this page).
|
||||
function create(onImageLoaded, W = 1280, H = 960, overrides = {}) {
|
||||
const ACTION = { x: W - PAD - 240, y: 132, w: 240, h: 60 };
|
||||
const ICON_ACTION = { x: ACTION.x - 60 - 16, y: ACTION.y, w: 60, h: 60 };
|
||||
// A narrow panel puts the pills on a row of their own under the tabs, so the tabs keep
|
||||
// their names; everything below moves down with it.
|
||||
const PILLS_Y = W < NARROW ? TAB_Y + TAB_H + 10 : TAB_Y;
|
||||
@@ -103,7 +98,7 @@
|
||||
// Name (+ badge) and the stats line beside the picture.
|
||||
function drawTitle(m) {
|
||||
const h = m.header;
|
||||
const maxW = (m.iconAction ? ICON_ACTION.x : ACTION.x) - NAME_X - 24;
|
||||
const maxW = ACTION.x - NAME_X - 24;
|
||||
g.textAlign = 'left'; g.textBaseline = 'middle';
|
||||
g.fillStyle = P.onSurface; g.font = T.font('titleLargeEmphasized', 36);
|
||||
const name = wrap(g, h ? h.title : m.loadingTitle, maxW - (h && h.badge ? 34 : 0), 1)[0] || '';
|
||||
@@ -122,9 +117,6 @@
|
||||
if (!a) return;
|
||||
ns.vrKit.button(g, regions, `action:${a.id}`, ACTION.x, ACTION.y, ACTION.w, ACTION.h,
|
||||
{ label: a.label, kind: KIND[a.look] || 'filled', enabled: a.look !== 'busy' }, ui);
|
||||
const i = m.iconAction; // the round icon button left of it
|
||||
if (i) ns.vrKit.button(g, regions, `action:${i.id}`, ICON_ACTION.x, ICON_ACTION.y, ICON_ACTION.w, ICON_ACTION.h,
|
||||
{ icon: i.icon, kind: KIND[i.look] || 'tonal', enabled: i.look !== 'busy' }, ui);
|
||||
}
|
||||
|
||||
// The featured card (id 'featured'): the thumbnail on the left (with its dot flags drawn
|
||||
@@ -457,12 +449,11 @@
|
||||
// (It also tells the sliding rows where the laser is: grid.js strips.)
|
||||
const hitTest = (u, v) => { grid.point(u * W, v * H); const r = regionAt(regions, u, v, W, H); return r ? { id: r.id } : null; };
|
||||
const has = id => regions.some(r => r.id === id); // an enabled control from the last draw?
|
||||
const rectOf = id => { const r = regions.find(x => x.id === id); return r ? { x: r.x, y: r.y, w: r.w, h: r.h } : null; };
|
||||
// Is (u, v) on the scrolling body of the last draw?
|
||||
const scrollAt = (u, v) => scrollable && u * W >= curView.x - 8 && u * W <= W - 16 && v * H >= curView.y && v * H <= curView.y + curView.h;
|
||||
|
||||
return {
|
||||
canvas, aspect: H / W, draw, hitTest, has, rectOf, scrollAt, imagesReady: kit.imagesReady, perPage: grid.PER_PAGE, animating: () => motion.moving() || grid.moving(),
|
||||
canvas, aspect: H / W, draw, hitTest, has, scrollAt, imagesReady: kit.imagesReady, perPage: grid.PER_PAGE, animating: () => motion.moving() || grid.moving(),
|
||||
maxScroll: () => maxScrollPx, // how far the last drawn body can scroll (px)
|
||||
shownScroll: () => scrollPx, // where it actually is (after clamping)
|
||||
pxPerV: H, // canvas pixels per unit of v (for drag scrolling)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//
|
||||
// ns.vrSymbols.draw(g, name, cx, cy, size, filled) centred on (cx, cy), `size` px square;
|
||||
// returns false for an unknown name
|
||||
// ns.vrSymbols.drawIcon(g, icon, cx, cy, size, filled) icon: a name, or { name, color, filled }
|
||||
// ns.vrSymbols.has(name), ns.vrSymbols.NAMES
|
||||
//
|
||||
// Material Symbols: Copyright Google LLC, Apache License 2.0
|
||||
@@ -45,12 +44,6 @@
|
||||
'3d_rotation': [
|
||||
'M620-360H520q-8 0-14-6t-6-14v-200q0-8 6-14t14-6h100q17 0 28.5 11.5T660-560v160q0 17-11.5 28.5T620-360Zm-200 0h-90q-13 0-21.5-8.5T300-390q0-13 8.5-21.5T330-420h70v-40h-40q-8 0-14-6t-6-14q0-8 6-14t14-6h40v-40h-70q-13 0-21.5-8.5T300-570q0-13 8.5-21.5T330-600h90q17 0 28.5 11.5T460-560v160q0 17-11.5 28.5T420-360Zm140-60h40v-120h-40v120Zm280-20q-17 0-28.5-11.5T800-480q0-115-72.5-203T542-794l30 30q11 11 11 28t-11 28q-11 11-28 11t-28-11L387-837q-8-8-5-20t15-15q20-5 41-6.5t42-1.5q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 17-11.5 28.5T840-440ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-17 11.5-28.5T120-520q17 0 28.5 11.5T160-480q0 115 72.5 203T418-166l-30-30q-11-11-11-28t11-28q11-11 28-11t28 11l129 129q8 8 5 20t-15 15q-20 5-41 6.5T480-80Z',
|
||||
'M620-360H520q-8 0-14-6t-6-14v-200q0-8 6-14t14-6h100q17 0 28.5 11.5T660-560v160q0 17-11.5 28.5T620-360Zm-200 0h-90q-13 0-21.5-8.5T300-390q0-13 8.5-21.5T330-420h70v-40h-40q-8 0-14-6t-6-14q0-8 6-14t14-6h40v-40h-70q-13 0-21.5-8.5T300-570q0-13 8.5-21.5T330-600h90q17 0 28.5 11.5T460-560v160q0 17-11.5 28.5T420-360Zm140-60h40v-120h-40v120Zm280-20q-17 0-28.5-11.5T800-480q0-115-72.5-203T542-794l30 30q11 11 11 28t-11 28q-11 11-28 11t-28-11L387-837q-8-8-4.5-20t14.5-15q20-5 41-6.5t42-1.5q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 17-11.5 28.5T840-440ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-17 11.5-28.5T120-520q17 0 28.5 11.5T160-480q0 115 72.5 203T418-166l-30-30q-11-11-11-28t11-28q11-11 28-11t28 11l129 129q8 8 4.5 20T563-88q-20 5-41 6.5T480-80Z'],
|
||||
'2d_2': [
|
||||
'M376-279H172q-11 0-18-7t-7-18v-32q0-5 1.5-10.5t6.5-8.5q51-41 84.5-68.5T292-478q16-22 24-41.5t8-46.5q0-23-17-39.5T266-622q-17 0-29.5 9.5T216-589q-7 13-20.5 18t-26.5-1q-14-7-17-22t3-29q14-32 45.5-49.5T266-690q63 0 97.5 32t34.5 90q0 37-14 69t-36 61q-23 31-46.5 50.5T249-346l2 3h125q14 0 23 9t9 23q0 14-9 23t-23 9Zm152-1q-17 0-28.5-11.5T488-320v-321q0-17 11.5-28.5T528-681h94q97 0 148.5 52T822-480q0 97-52 148.5T622-280h-94Zm36-72h54q66 0 97.5-31t31.5-97q0-66-31.5-97.5T618-609h-54v257Z',
|
||||
'M376-279H172q-11 0-18-7t-7-18v-32q0-5 1.5-10.5t6.5-8.5q51-41 84.5-68.5T292-478q16-22 24-40.5t8-38.5q0-30-17-47.5T266-622q-17 0-29.5 9.5T216-589q-7 13-20.5 18t-26.5-1q-14-7-17-22t3-29q14-32 45.5-49.5T266-690q63 0 97.5 32t34.5 90q0 37-14 69t-36 61q-23 31-46.5 50.5T249-346l2 3h125q14 0 23 9t9 23q0 14-9 23t-23 9Zm152-1q-17 0-28.5-11.5T488-320v-321q0-17 11.5-28.5T528-681h94q97 0 148.5 52T822-480q0 97-52 148.5T622-280h-94Zm36-72h54q66 0 97.5-31t31.5-97q0-66-31.5-97.5T618-609h-54v257Z'],
|
||||
'3d_2': [
|
||||
'M530-279q-17 0-28.5-11.5T490-319v-321q0-17 11.5-28.5T530-680h93q97 0 148.5 51.5T823-480q0 97-52 149t-148 52h-93Zm35-72h55q66 0 97-31.5t31-97.5q0-66-31-97.5T620-609h-55v258Zm-289 81q-45 0-78-17.5T149-340q-7-14-1-29t20-22q14-7 28.5-1.5T219-373q8 16 22.5 24.5T276-340q28 0 45-15.5t17-42.5q0-26-18-43t-51-17h-2q-14 0-23-9t-9-23q0-14 9-23t23-9q30 0 45-14t15-37q0-23-14.5-36.5T274-623q-18 0-31 8t-21 22q-7 14-21 18t-28-3q-14-7-18-21t3-28q6-12 14.5-21.5T192-666q16-11 37-17t46-6q55 0 89.5 27.5T399-581q0 30-13.5 50.5T350-496v4q30 11 46 39t16 61q0 59-37.5 90.5T276-270Z',
|
||||
'M530-279q-17 0-28.5-11.5T490-319v-321q0-17 11.5-28.5T530-680h93q97 0 148.5 51.5T823-480q0 97-52 149t-148 52h-93Zm35-72h55q66 0 97-31.5t31-97.5q0-66-31-97.5T620-609h-55v258Zm-289 81q-45 0-78-17.5T149-340q-7-14-1-29t20-22q14-7 28.5-1.5T219-373q8 16 22.5 24.5T276-340q28 0 45-15.5t17-42.5q0-26-18-43t-51-17h-2q-14 0-23-9t-9-23q0-14 9-23t23-9q30 0 45-14t15-37q0-23-14.5-36.5T274-623q-18 0-31 8t-21 22q-7 14-21 18t-28-3q-14-7-18-21t3-28q6-12 14.5-21.5T192-666q16-11 37-17t46-6q55 0 89.5 27.5T399-581q0 30-13.5 50.5T350-496v4q30 11 46 39t16 61q0 59-37.5 90.5T276-270Z'],
|
||||
account_box: [
|
||||
'M200-246q54-53 125.5-83.5T480-360q83 0 154.5 30.5T760-246v-514H200v514Zm280-194q58 0 99-41t41-99q0-58-41-99t-99-41q-58 0-99 41t-41 99q0 58 41 99t99 41ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm80-80h400v-10q-42-35-93-52.5T480-280q-56 0-107 17.5T280-210v10Zm200-320q-25 0-42.5-17.5T420-580q0-25 17.5-42.5T480-640q25 0 42.5 17.5T540-580q0 25-17.5 42.5T480-520Zm0 17Z',
|
||||
'M480-440q58 0 99-41t41-99q0-58-41-99t-99-41q-58 0-99 41t-41 99q0 58 41 99t99 41ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-46q-54-53-125.5-83.5T480-360q-83 0-154.5 30.5T200-246v46Z'],
|
||||
@@ -123,9 +116,6 @@
|
||||
comment: [
|
||||
'M280-400h400q17 0 28.5-11.5T720-440q0-17-11.5-28.5T680-480H280q-17 0-28.5 11.5T240-440q0 17 11.5 28.5T280-400Zm0-120h400q17 0 28.5-11.5T720-560q0-17-11.5-28.5T680-600H280q-17 0-28.5 11.5T240-560q0 17 11.5 28.5T280-520Zm0-120h400q17 0 28.5-11.5T720-680q0-17-11.5-28.5T680-720H280q-17 0-28.5 11.5T240-680q0 17 11.5 28.5T280-640ZM160-240q-33 0-56.5-23.5T80-320v-480q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v623q0 27-24.5 37.5T812-148l-92-92H160Zm594-80 46 45v-525H160v480h594Zm-594 0v-480 480Z',
|
||||
'M280-400h400q17 0 28.5-11.5T720-440q0-17-11.5-28.5T680-480H280q-17 0-28.5 11.5T240-440q0 17 11.5 28.5T280-400Zm0-120h400q17 0 28.5-11.5T720-560q0-17-11.5-28.5T680-600H280q-17 0-28.5 11.5T240-560q0 17 11.5 28.5T280-520Zm0-120h400q17 0 28.5-11.5T720-680q0-17-11.5-28.5T680-720H280q-17 0-28.5 11.5T240-680q0 17 11.5 28.5T280-640ZM160-240q-33 0-56.5-23.5T80-320v-480q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v623q0 27-24.5 37.5T812-148l-92-92H160Z'],
|
||||
diamond: [
|
||||
'M480-165q-17 0-33-7.5T419-194L113-560q-9-11-13.5-24T95-611q0-9 1.5-18.5T103-647l75-149q11-20 29.5-32t41.5-12h462q23 0 41.5 12t29.5 32l75 149q5 8 6.5 17.5T865-611q0 14-4.5 27T847-560L541-194q-12 14-28 21.5t-33 7.5Zm-95-475h190l-60-120h-70l-60 120Zm55 347v-267H218l222 267Zm80 0 222-267H520v267Zm144-347h106l-60-120H604l60 120Zm-474 0h106l60-120H250l-60 120Z',
|
||||
'M368-630h224L486-840h-12L368-630Zm82 474v-414H105l345 414Zm60 0 345-414H510v414Zm148-474h207l-83-166q-11-20-29.5-32T711-840H554l104 210Zm-563 0h207l104-210H249q-23 0-41.5 12T178-796L95-630Z'],
|
||||
drag_handle: [
|
||||
'M200-360q-17 0-28.5-11.5T160-400q0-17 11.5-28.5T200-440h560q17 0 28.5 11.5T800-400q0 17-11.5 28.5T760-360H200Zm0-160q-17 0-28.5-11.5T160-560q0-17 11.5-28.5T200-600h560q17 0 28.5 11.5T800-560q0 17-11.5 28.5T760-520H200Z',
|
||||
'M200-360q-17 0-28.5-11.5T160-400q0-17 11.5-28.5T200-440h560q17 0 28.5 11.5T800-400q0 17-11.5 28.5T760-360H200Zm0-160q-17 0-28.5-11.5T160-560q0-17 11.5-28.5T200-600h560q17 0 28.5 11.5T800-560q0 17-11.5 28.5T760-520H200Z'],
|
||||
@@ -141,9 +131,6 @@
|
||||
explore: [
|
||||
'M480-440q-17 0-28.5-11.5T440-480q0-17 11.5-28.5T480-520q17 0 28.5 11.5T520-480q0 17-11.5 28.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320ZM297-277l250-117q6-3 11-8t8-11l117-250q5-10-2.5-17.5T663-683L413-566q-6 3-11 8t-8 11L277-297q-5 10 2.5 17.5T297-277Z',
|
||||
'M480-440q-17 0-28.5-11.5T440-480q0-17 11.5-28.5T480-520q17 0 28.5 11.5T520-480q0 17-11.5 28.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0 0q-134 0-227-93t-93-227q0-134 93-227t227-93q134 0 227 93t93 227q0 134-93 227t-227 93Zm67-234q6-3 11-8t8-11l117-250q5-10-2.5-17.5T663-683L413-566q-6 3-11 8t-8 11L277-297q-5 10 2.5 17.5T297-277l250-117Z'],
|
||||
fast_forward: [
|
||||
'M100-315v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T428-480q0 10-4.5 18.5T410-447L162-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Zm400 0v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T828-480q0 10-4.5 18.5T810-447L562-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29ZM180-480Zm400 0Zm-400 90 136-90-136-90v180Zm400 0 136-90-136-90v180Z',
|
||||
'M100-315v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T428-480q0 10-4.5 18.5T410-447L162-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Zm400 0v-330q0-18 12-29t28-11q5 0 11 1t11 5l248 166q9 6 13.5 14.5T828-480q0 10-4.5 18.5T810-447L562-281q-5 4-11 5t-11 1q-16 0-28-11t-12-29Z'],
|
||||
favorite: [
|
||||
'M480-147q-14 0-28.5-5T426-168l-69-63q-106-97-191.5-192.5T80-634q0-94 63-157t157-63q53 0 100 22.5t80 61.5q33-39 80-61.5T660-854q94 0 157 63t63 157q0 115-85 211T602-230l-68 62q-11 11-25.5 16t-28.5 5Zm-38-543q-29-41-62-62.5T300-774q-60 0-100 40t-40 100q0 52 37 110.5T285.5-410q51.5 55 106 103t88.5 79q34-31 88.5-79t106-103Q726-465 763-523.5T800-634q0-60-40-100t-100-40q-47 0-80 21.5T518-690q-7 10-17 15t-21 5q-11 0-21-5t-17-15Zm38 189Z',
|
||||
'M480-147q-14 0-28.5-5T426-168l-69-63q-106-97-191.5-192.5T80-634q0-94 63-157t157-63q53 0 100 22.5t80 61.5q33-39 80-61.5T660-854q94 0 157 63t63 157q0 115-85 211T602-230l-68 62q-11 11-25.5 16t-28.5 5Z'],
|
||||
@@ -204,9 +191,6 @@
|
||||
notifications_active: [
|
||||
'M200-200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-83 50-147.5T420-792v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 84.5T720-560v280h40q17 0 28.5 11.5T800-240q0 17-11.5 28.5T760-200H200Zm280-300Zm0 420q-33 0-56.5-23.5T400-160h160q0 33-23.5 56.5T480-80ZM320-280h320v-280q0-66-47-113t-113-47q-66 0-113 47t-47 113v280ZM120-560q-17 0-28.5-13T82-603q8-75 42-139.5T211-855q13-11 29.5-10t26.5 15q10 14 8 30t-15 28q-39 37-64 86t-33 106q-2 17-14 28.5T120-560Zm720 0q-17 0-29-11.5T797-600q-8-57-33-106t-64-86q-13-12-15-28t8-30q10-14 26.5-15t29.5 10q53 48 87 112.5T878-603q2 17-9.5 30T840-560Z',
|
||||
'M200-200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-83 50-147.5T420-792v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 84.5T720-560v280h40q17 0 28.5 11.5T800-240q0 17-11.5 28.5T760-200H200ZM480-80q-33 0-56.5-23.5T400-160h160q0 33-23.5 56.5T480-80ZM120-560q-17 0-28.5-13T82-603q8-75 42-139.5T211-855q13-11 29.5-10t26.5 15q10 14 8 30t-15 28q-39 37-64 86t-33 106q-2 17-14 28.5T120-560Zm720 0q-17 0-29-11.5T797-600q-8-57-33-106t-64-86q-13-12-15-28t8-30q10-14 26.5-15t29.5 10q53 48 87 112.5T878-603q2 17-9.5 30T840-560Z'],
|
||||
notifications_off: [
|
||||
'M646-200H200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-33 8.5-65t25.5-61l60 60q-7 16-10.5 32.5T320-560v280h248L84-764q-11-11-11-28t11-28q11-11 28-11t28 11l680 680q11 11 11.5 27.5T820-84q-11 11-28 11t-28-11L646-200ZM540-792q80 20 130 84.5T720-560v110q0 20-12.5 30T680-410q-15 0-27.5-10.5T640-451v-109q0-66-47-113t-113-47q-16 0-34 4t-32 10q-17 7-33.5 3T355-722q-8-13-5.5-27.5T365-771q13-7 27-12t28-9v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28Zm-96 388Zm36 324q-30 0-53.5-16.5T403-141q0-8 6.5-13.5T424-160h112q8 0 14.5 5.5T557-141q0 28-23.5 44.5T480-80Zm33-481Z',
|
||||
'M646-200H200q-17 0-28.5-11.5T160-240q0-17 11.5-28.5T200-280h40v-280q0-33 8.5-65t25.5-61l126 126H288L84-764q-11-11-11-28t11-28q11-11 28-11t28 11l680 680q11 11 11.5 27.5T820-84q-11 11-28 11t-28-11L646-200Zm74-251q0 12-7 22t-18 15q-11 5-23 2.5T652-422L367-707q-7-7-10-15t-3-17q0-11 5.5-21.5T375-776q11-5 22-9t23-7v-28q0-25 17.5-42.5T480-880q25 0 42.5 17.5T540-820v28q80 20 130 85t50 147v109ZM480-80q-30 0-53.5-16.5T403-141q0-8 6.5-13.5T424-160h112q8 0 14.5 5.5T557-141q0 28-23.5 44.5T480-80Z'],
|
||||
open_in_full: [
|
||||
'M160-120q-17 0-28.5-11.5T120-160v-240q0-17 11.5-28.5T160-440q17 0 28.5 11.5T200-400v144l504-504H560q-17 0-28.5-11.5T520-800q0-17 11.5-28.5T560-840h240q17 0 28.5 11.5T840-800v240q0 17-11.5 28.5T800-520q-17 0-28.5-11.5T760-560v-144L256-200h144q17 0 28.5 11.5T440-160q0 17-11.5 28.5T400-120H160Z',
|
||||
'M160-120q-17 0-28.5-11.5T120-160v-240q0-17 11.5-28.5T160-440q17 0 28.5 11.5T200-400v144l504-504H560q-17 0-28.5-11.5T520-800q0-17 11.5-28.5T560-840h240q17 0 28.5 11.5T840-800v240q0 17-11.5 28.5T800-520q-17 0-28.5-11.5T760-560v-144L256-200h144q17 0 28.5 11.5T440-160q0 17-11.5 28.5T400-120H160Z'],
|
||||
@@ -384,17 +368,5 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
// An icon given as a name, or as { name, color (a colour spec: tokens.js resolve, e.g.
|
||||
// 'primary' or '#ff0033'), filled }: drawn like draw(), in its own colour when it has one
|
||||
// (e.g. a site's mark that keeps its brand colour in a menu row).
|
||||
function drawIcon(g, icon, cx, cy, size, filled = false) {
|
||||
if (!icon || typeof icon === 'string') return draw(g, icon, cx, cy, size, filled);
|
||||
g.save();
|
||||
if (icon.color) g.fillStyle = ns.vrTokens.resolve(icon.color);
|
||||
const ok = draw(g, icon.name, cx, cy, size, icon.filled != null ? !!icon.filled : filled);
|
||||
g.restore();
|
||||
return ok;
|
||||
}
|
||||
|
||||
ns.vrSymbols = { draw, drawIcon, has: name => name in PATHS, NAMES: Object.keys(PATHS) };
|
||||
ns.vrSymbols = { draw, has: name => name in PATHS, NAMES: Object.keys(PATHS) };
|
||||
})(globalThis.__streamxr = globalThis.__streamxr || {});
|
||||
@@ -19,7 +19,7 @@
|
||||
// whatever the site's panels use,
|
||||
// state() -> { title, time, duration, paused,
|
||||
// nowPlaying (what the controls panel shows: see panel-ui.js),
|
||||
// quality: { current, currentLabel, levels: [{ id, label, icons?, disabled? }] },
|
||||
// quality: { current, currentLabel, levels: [{ id, label, icons? }] },
|
||||
// live: null | { atHead, behind (seconds), dvr } (a live stream; time
|
||||
// and duration then cover only the rewindable part),
|
||||
// speed, speeds: [rates], volume: { level, muted },
|
||||
@@ -27,7 +27,7 @@
|
||||
// intensity (0..1) }] (how much each part is rewatched; optional),
|
||||
// ad: null | { ... },
|
||||
// format: { current, options: [{ id, label, short, disabled }] },
|
||||
// extras: { left: [...], right: [...], seek } (the site's own buttons on
|
||||
// extras: { left: [...], right: [...] } (the site's own buttons on
|
||||
// the controls panel: panel-ui.js, plus menu: null | { items: [{ id,
|
||||
// text, on, disabled }], note, scroll, width, stay (stays open on a
|
||||
// choice) }: opens like Quality's),
|
||||
@@ -127,12 +127,6 @@
|
||||
close: () => { if (isOpen(reg.id)) toggleOpen(reg.id); },
|
||||
isOpen: () => isOpen(reg.id),
|
||||
closeMenu: () => { menu = null; },
|
||||
panel: id => controller(id), // another of the site's panels' controllers (e.g. to show something in it)
|
||||
// Open (or close, if it's open) a floating menu on one of this panel's controls
|
||||
// (`local`, e.g. 'action:bell'; the style needs rectOf). Its items come from the
|
||||
// controller's menu(local) each frame; a choice arrives as { type: 'menu', value:
|
||||
// item id, menu: local }.
|
||||
openMenu: local => { const id = `p:${reg.id}${SEP}${local}`; menu = menu === id ? null : id; lastActivity = performance.now(); },
|
||||
confirm: d => confirm(d),
|
||||
typeInto: (on, opts) => typeInto(p, on, opts),
|
||||
});
|
||||
@@ -180,12 +174,6 @@
|
||||
const SEP = '|';
|
||||
const panelOf = id => (id && id.includes(SEP) ? panels.get(id.slice(0, id.indexOf(SEP))) : null);
|
||||
const localOf = (id, p) => (id && id.startsWith(p.reg.id + SEP) ? id.slice(p.reg.id.length + 1) : null);
|
||||
// A side panel's menu id ('p:<panel>|<control>', host.openMenu) -> { p, local } or null.
|
||||
function panelMenu(id) {
|
||||
if (!id || !id.startsWith('p:')) return null;
|
||||
const rest = id.slice(2), i = rest.indexOf(SEP), p = i < 0 ? null : panels.get(rest.slice(0, i));
|
||||
return p ? { p, local: rest.slice(i + 1) } : null;
|
||||
}
|
||||
// Tell a panel's controller one of its controls was used: '<type>:<value>' -> event.
|
||||
function sendEvent(p, local, extra) {
|
||||
const i = local.indexOf(':');
|
||||
@@ -238,18 +226,7 @@
|
||||
// Where the menu goes: its bottom middle just above the button that opened it (centred
|
||||
// on it, kept from reaching far past the panel's sides), in the controls panel's tilt,
|
||||
// 32 dp in front.
|
||||
// A side panel's menu ('p:<panel>|<control>') goes the same way above its control, in
|
||||
// that panel's pose.
|
||||
function menuModel(id) {
|
||||
const pm = panelMenu(id);
|
||||
if (pm) {
|
||||
const r = pm.p.style.rectOf && pm.p.style.rectOf(pm.local), c = pm.p.style.canvas, sz = pm.p.size;
|
||||
if (!r) return null;
|
||||
const half = Math.max(0, sz[0] / 2 - 0.12);
|
||||
const x = Math.max(-half, Math.min(half, ((r.x + r.w / 2) / c.width - 0.5) * sz[0]));
|
||||
const bottom = (0.5 - r.y / c.height) * sz[1] + 12 * MPX;
|
||||
return M.multiply(panelModel(pm.p), M.translation(x, bottom + menuSize[1] / 2, dp(32)));
|
||||
}
|
||||
const rect = ui.rectOf(id);
|
||||
if (!rect) return null;
|
||||
const cx = ((rect.x + rect.w / 2) / ui.width - 0.5) * size[0];
|
||||
@@ -663,13 +640,6 @@
|
||||
function click(id) {
|
||||
const p = panelOf(id);
|
||||
if (p) { sendEvent(p, localOf(id, p)); return; }
|
||||
if (id.startsWith('pi:')) { // an item of a side panel's menu: 'pi:<index>'
|
||||
const pm = panelMenu(menu), mm = pm && pm.p.ctrl.menu ? pm.p.ctrl.menu(pm.local) : null;
|
||||
const it = mm && (mm.items || [])[+id.slice(3)];
|
||||
if (it && !it.disabled) pm.p.ctrl.event({ type: 'menu', value: it.id, menu: pm.local });
|
||||
if (!(mm && mm.stay)) menu = null;
|
||||
return;
|
||||
}
|
||||
if (id.startsWith('toggle:')) {
|
||||
const pid = id.slice(7);
|
||||
toggleOpen(pid);
|
||||
@@ -1117,12 +1087,12 @@
|
||||
let menuState = null;
|
||||
if (menu === 'quality') {
|
||||
menuState = {
|
||||
items: quality.levels.map(q => ({ id: `q:${q.id}`, text: q.label, on: q.id === quality.current, icons: q.icons, disabled: !!q.disabled })),
|
||||
items: quality.levels.map(q => ({ id: `q:${q.id}`, text: q.label, on: q.id === quality.current, icons: q.icons })),
|
||||
note: quality.levels.length ? null : tr('panel.noQualities', { site: site.name || tr('panel.theSite') }),
|
||||
};
|
||||
} else if (menu === 'format') {
|
||||
menuState = {
|
||||
items: format.options.map(o => ({ id: `f:${o.id}`, text: o.short, on: o.id === format.current, disabled: o.disabled, icons: [ns.vrPanelUI.formatIcon(o.id)] })),
|
||||
items: format.options.map(o => ({ id: `f:${o.id}`, text: o.short, on: o.id === format.current, disabled: o.disabled })),
|
||||
note: null,
|
||||
};
|
||||
} else if (menu === 'speed') {
|
||||
@@ -1132,10 +1102,6 @@
|
||||
const x = extraOf(menu.slice(2)), mm = x && x.menu;
|
||||
if (mm) menuState = { ...mm, key: menu, items: (mm.items || []).map(it => ({ ...it, id: `xi:${x.id}:${it.id}` })) };
|
||||
else menu = null; // the button went away
|
||||
} else if (panelMenu(menu)) { // a side panel's menu (its items' ids: 'pi:<index>')
|
||||
const pm = panelMenu(menu), mm = shown(pm.p) && pm.p.ctrl.menu ? pm.p.ctrl.menu(pm.local) : null;
|
||||
if (mm) menuState = { ...mm, key: menu, items: (mm.items || []).map((it, i) => ({ ...it, id: `pi:${i}` })) };
|
||||
else menu = null; // the panel closed, or has no menu there now
|
||||
}
|
||||
const volume = st.volume || { level: 100, muted: false };
|
||||
muted = !!volume.muted;
|
||||
@@ -1159,7 +1125,7 @@
|
||||
quality, format: { id: format.current, label: currentFormat ? currentFormat.short : '' },
|
||||
speed: st.speed || 1, volume: shownVolume, chapters: st.chapters || [], heatmap: st.heatmap || [], preview, ad,
|
||||
menuOpen: menu, grabbing: !!grab,
|
||||
extras: extrasNow ? { left: (extrasNow.left || []).map(stripMenu), right: (extrasNow.right || []).map(stripMenu), seek: extrasNow.seek || null } : null,
|
||||
extras: extrasNow ? { left: (extrasNow.left || []).map(stripMenu), right: (extrasNow.right || []).map(stripMenu) } : null,
|
||||
};
|
||||
const pf = preview && preview.frame;
|
||||
const key = [state.title, Math.floor(state.time), Math.floor(state.duration), state.paused, state.buffered != null ? Math.floor(state.buffered) : '',
|
||||
@@ -1314,9 +1280,7 @@
|
||||
}
|
||||
if (hoverShown && !kbShown && cpA > 0.01) items.push([hoverPass, hoverCardModel(hoverShown.rect), hoverSize, null, cpA]);
|
||||
// The floating menu, growing up from its button (its canvas's bottom edge).
|
||||
// (A side panel's menu fades with its panel's side instead of the controls panel.)
|
||||
const menuA = panelMenu(menuShown) ? (panelMenu(menuShown).p.reg.stays ? 1 : V * B) : cpA;
|
||||
const MA = Math.min(1, Math.max(0, anim.menu.get())) * menuA, mm = MA > 0.01 && menuShown ? menuModel(menuShown) : null;
|
||||
const MA = Math.min(1, Math.max(0, anim.menu.get())) * cpA, mm = MA > 0.01 && menuShown ? menuModel(menuShown) : null;
|
||||
if (mm) items.push([menuPass, scaled(mm, [0, -menuSize[1] / 2], shrink(anim.menu.get())), menuSize, backdrop, MA]);
|
||||
// Settled ones first (writing depth), then the ones still fading.
|
||||
items.sort((a, b) => (b[4] > 0.99) - (a[4] > 0.99));
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// YouTube's feed panels, both in the Catalog style: Explore (YouTube's Home feed with its
|
||||
// topic chips, then the Explore pages of YouTube's left menu) and You (your own feeds, signed
|
||||
// in: Subscriptions, Channels (the ones you subscribe to: a click opens one in the Channel
|
||||
// panel), Your channel, History, Playlists, Watch later, Liked videos, Movies &
|
||||
// in: Subscriptions, Your channel, History, Playlists, Watch later, Liked videos, Movies &
|
||||
// TV). Each shows its sources as tabs (the row slides when they don't all fit), the loaded
|
||||
// videos (a pager that loads more pages as you page through) and the current page. A source
|
||||
// that isn't built yet shows "Coming soon" with the construction icon. A source loads the
|
||||
// first time it shows. Panel controllers: see panels.js.
|
||||
// actions used: browseHome(chip) -> pager (youtube-home.js home(); p.chips after a page),
|
||||
// browseSubscriptions() -> pager (youtube-subscriptions.js feed()), browseChannels() ->
|
||||
// pager (youtube-subscriptions.js channels()), browseFeed(name) ->
|
||||
// browseSubscriptions() -> pager (youtube-subscriptions.js feed()), browseFeed(name) ->
|
||||
// pager (youtube-home.js feedVideos()), browsePlaylist(id) -> pager (youtube-content.js
|
||||
// playlistVideos()), myChannelId() -> Promise of id | null (youtube-home.js),
|
||||
// channelHeader(id) -> Promise of header (its tabs), channelList(id, tab) -> pager
|
||||
@@ -23,11 +21,8 @@
|
||||
// in the string tables, and with `note` the grey line under the tabs 'yt.browse.<id>Note'),
|
||||
// icon (Material Symbols, symbols.js), and either load(actions, chip) (makes the pager;
|
||||
// `list` is the playlist its videos play in) with optional chips, or soon (not built yet:
|
||||
// "Coming soon"). card (optional): how an item becomes a card (default: a video card);
|
||||
// pick(host, item) (optional): what clicking one does (default: play it). chips: 'home'
|
||||
// (the Home feed's topic chips, from its pager), 'sorts' (the sort choices its pager
|
||||
// offers, p.sorts: [{ text, selected }]), or a fixed list of chip ids (named
|
||||
// 'yt.channel.<id>'), the first one chosen at first.
|
||||
// "Coming soon"). chips: 'home' (the Home feed's topic chips, from its pager), or a fixed
|
||||
// list of chip ids (named 'yt.channel.<id>'), the first one chosen at first.
|
||||
const EXPLORE = [
|
||||
{ id: 'home', icon: 'home', load: (a, chip) => a.browseHome(chip), chips: 'home' },
|
||||
{ id: 'shorts', icon: 'smart_display', soon: true },
|
||||
@@ -42,28 +37,9 @@
|
||||
{ id: 'podcasts', icon: 'podcasts', soon: true },
|
||||
{ id: 'memberships', icon: 'card_membership', soon: true },
|
||||
];
|
||||
// A subscribed channel (youtube-subscriptions.js channels()) as a card: its round picture,
|
||||
// name, handle · subscribers, and the bell's setting when YouTube offers one; a flag with
|
||||
// its section's title when it's listed apart (e.g. "Purchased": a channel you're a member of).
|
||||
function channelCard(c) {
|
||||
const n = c.notifications || {}, cur = String(n.current || '').toLowerCase();
|
||||
const now = (n.options || []).find(o => o.selected) || (n.options || []).find(o => cur && cur.includes(String(o.label).toLowerCase()));
|
||||
const bell = now ? now.label : '';
|
||||
return { title: c.title, thumb: c.thumb, fit: 'round', flags: c.aside ? [{ text: c.aside, bg: 'tertiary', fg: 'surface' }] : [],
|
||||
lines: [{ text: [c.handle, c.subscribers].filter(Boolean).join(' · '), badge: c.badge || null },
|
||||
...(bell ? [{ text: tr('yt.bell.line', { name: bell }) }] : [])] };
|
||||
}
|
||||
|
||||
// Clicking a channel: show it in the Channel panel (left of the zone).
|
||||
function showChannel(host, c) {
|
||||
const ch = host.panel && host.panel('channel');
|
||||
if (ch && ch.show) ch.show(c.id, { subscribed: true, notifications: c.notifications });
|
||||
}
|
||||
|
||||
const MY_TABS = ['videos', 'shorts', 'live', 'playlists']; // Your channel's chips (those it has)
|
||||
const YOU = [
|
||||
{ id: 'subs', icon: 'subscriptions', load: a => a.browseSubscriptions(), note: true },
|
||||
{ id: 'channels', icon: 'person', load: (a, sort) => a.browseChannels(sort), chips: 'sorts', note: true, card: channelCard, pick: showChannel },
|
||||
{ id: 'mychannel', icon: 'account_box', load: (a, chip) => myChannel(a, chip, MY_TABS), chips: MY_TABS },
|
||||
{ id: 'history', icon: 'history', load: a => a.browseFeed('history'), note: true },
|
||||
{ id: 'playlists', icon: 'playlist_play', load: a => a.browseFeed('playlists'), note: true },
|
||||
@@ -106,7 +82,6 @@
|
||||
const perPage = host.perPage;
|
||||
let source = sources[0].id;
|
||||
let homeChips = []; // the Home feed's chip texts on offer, in display order
|
||||
const sortsOf = {}; // source id -> the sort choices its pager offers (chips: 'sorts')
|
||||
const chipOf = { home: 'All' }; // source id -> its chosen chip id
|
||||
const availOf = {}; // source id -> the fixed chips it turned out to have (p.tabs)
|
||||
let pager = null, page = 0, loading = false, error = '', ver = 0;
|
||||
@@ -119,7 +94,6 @@
|
||||
// A source's chips ([{ id, label }]) and its chosen one.
|
||||
function chipList(s = current()) {
|
||||
if (s.chips === 'home') return homeChips.map(t => ({ id: t, label: t }));
|
||||
if (s.chips === 'sorts') return (sortsOf[s.id] || []).map(t => ({ id: t, label: t }));
|
||||
return (availOf[s.id] || s.chips || []).map(id => ({ id, label: tr(`yt.channel.${id}`) }));
|
||||
}
|
||||
const chipNow = (s = current()) => chipOf[s.id] || (Array.isArray(s.chips) ? s.chips[0] : null);
|
||||
@@ -139,12 +113,6 @@
|
||||
loading = true; bump();
|
||||
p.next().then(() => {
|
||||
if (p === pager && current().chips === 'home' && p.chips && p.chips.length) homeChips = orderChips(p.chips);
|
||||
// A list with its own sort choices (the Channels list): offered as its pills, the one
|
||||
// it's sorted by chosen.
|
||||
if (p === pager && current().chips === 'sorts' && p.sorts && p.sorts.length) {
|
||||
sortsOf[source] = p.sorts.map(x => x.text);
|
||||
const on = p.sorts.find(x => x.selected); if (on) chipOf[source] = on.text;
|
||||
}
|
||||
// A list that found out which of its chips exist (Your channel's tabs), and which it read.
|
||||
if (p === pager && p.tabs && p.tabs.length) { availOf[source] = p.tabs; if (p.tab) chipOf[source] = p.tab; }
|
||||
}, err => { if (p === pager) error = tr('yt.browse.failed', { error: err && err.message || err }); })
|
||||
@@ -173,8 +141,7 @@
|
||||
page = ev.value === 'next' ? Math.min(pageCount() - 1, page + 1) : Math.max(0, page - 1); bump();
|
||||
} else if (ev.type === 'item') {
|
||||
const it = items()[page * perPage + +ev.value];
|
||||
if (it && current().pick) current().pick(host, it);
|
||||
else if (it) actions.openVideo(it.id, it.playlistId || current().list);
|
||||
if (it) actions.openVideo(it.id, it.playlistId || current().list);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +157,7 @@
|
||||
title: tr(title), tabs: tabs(), tab: source,
|
||||
pills: chips, pill: chips.length ? chipNow(s) : null,
|
||||
note: s.note ? tr(`yt.browse.${source}Note`) : '',
|
||||
items: items().slice(page * perPage, page * perPage + perPage).map(it => (s.card ? s.card(it) : ns.ytVr.card(it))),
|
||||
items: items().slice(page * perPage, page * perPage + perPage).map(it => ns.ytVr.card(it)),
|
||||
page, pageCount: pageCount(), more: !!pager && !pager.done, loading,
|
||||
emptyText: s.soon ? tr('yt.comingSoon') : loading ? tr('yt.loading') : message || tr('yt.nothingYet'),
|
||||
emptyIcon: s.soon ? 'construction' : null,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// YouTube's "Channel" panel (Profile style): which channel is open, its header, the chosen
|
||||
// tab and sort, the loaded videos (a pager that loads more pages as you page through), the
|
||||
// current page, the channel's featured video (a "Featured" tab, first, when the channel
|
||||
// has one), and the Subscribe button (unsubscribing asks in a dialog), with the notification
|
||||
// bell beside it once subscribed (a round button opening a menu of YouTube's choices). Three tabs aren't
|
||||
// has one), and the Subscribe button (unsubscribing asks in a dialog). Three tabs aren't
|
||||
// video grids: Store (product cards, just to look at: buying leads off YouTube), Posts (a
|
||||
// scrolling feed, more loading near its end) and About (a scrolling page). On a post you
|
||||
// can like / dislike it and vote in its poll (signed in; shown at once, undone if YouTube
|
||||
@@ -17,16 +16,12 @@
|
||||
// openCurrent() show the playing video's channel
|
||||
// showCurrent() the channel name was clicked: fold the panel if it
|
||||
// shows that channel, else open it on it
|
||||
// show(id, hint) show a channel picked elsewhere (the You panel's
|
||||
// Channels list; hint: { subscribed, notifications })
|
||||
// and open the panel
|
||||
// noteSubscribed(id, on) a subscription changed elsewhere (controls panel)
|
||||
// channelId() the channel shown (or null)
|
||||
// actions used: channelHeader(id) -> Promise of header (youtube-channel.js channel()),
|
||||
// channelList(id, tab, sort) -> pager (Store: Promise of [products]), channelAbout(id) ->
|
||||
// Promise of about | null, channelFeatured(id) -> Promise of item | null,
|
||||
// setSubscribed(id, on) -> Promise of { ok, error }, setBell(option) -> Promise of { ok, error },
|
||||
// bellMatches(label, state), openVideo(id, playlistId), state(),
|
||||
// setSubscribed(id, on) -> Promise of { ok, error }, openVideo(id, playlistId), state(),
|
||||
// ratePost(post, what), votePoll(post, index, on) -> Promise of { ok, error },
|
||||
// postComments(post, sort) -> pager, replies(comment), rateComment(comment, what).
|
||||
(function (ns) {
|
||||
@@ -78,7 +73,6 @@
|
||||
const rated = new Map(); // post id -> 'liked' | 'disliked' | 'none' (your clicks)
|
||||
const voted = new Map(); // post id -> the poll choice you picked (-1: none) (your clicks)
|
||||
let sub = 'unknown'; // 'unknown' | 'subscribed' | 'unsubscribed' | 'busy'
|
||||
let bell = null; // the channel's bell: { state, options: [{ label, params, selected }], busy } | null
|
||||
let gen = 0, ver = 0; // gen: ignore answers for a channel/list no longer shown
|
||||
const bump = () => { ver++; };
|
||||
|
||||
@@ -231,22 +225,18 @@
|
||||
else if (k.length === 1) { search.text += k; bump(); }
|
||||
}
|
||||
|
||||
// bellHint: the channel's bell from elsewhere (the Channels list's notifications), until
|
||||
// the header brings its own.
|
||||
function openChannel(id, subscribedHint, bellHint) {
|
||||
function openChannel(id, subscribedHint) {
|
||||
if (!id || id === channelId) return;
|
||||
if (search) { searchFocus(false); search = null; }
|
||||
channelId = id; header = null; error = ''; pager = null; page = 0; loading = false; featured = null; about = undefined; scroll = 0; opened = null;
|
||||
tab = 'videos'; sort = SORTS.videos[0].id;
|
||||
sub = subscribedHint == null ? 'unknown' : subscribedHint ? 'subscribed' : 'unsubscribed';
|
||||
bell = bellOf(bellHint);
|
||||
const my = ++gen;
|
||||
bump();
|
||||
Promise.resolve(actions.channelHeader(id)).then(h => {
|
||||
if (my !== gen) return;
|
||||
header = h;
|
||||
if (h && h.subscribed != null && sub === 'unknown') sub = h.subscribed ? 'subscribed' : 'unsubscribed';
|
||||
if (!bell && h) bell = bellOf(h.notifications);
|
||||
const lists = tabs().filter(t => t.id !== 'featured');
|
||||
if (!lists.some(t => t.id === tab) && lists.length) { tab = lists[0].id; sort = firstSort(tab); }
|
||||
startList();
|
||||
@@ -281,43 +271,8 @@
|
||||
}, err => { if (id === channelId) { sub = before; console.warn('[streamxr] subscription change failed:', err); bump(); } });
|
||||
}
|
||||
|
||||
// ---- the notification bell (signed in and subscribed, when YouTube offers one)
|
||||
// The bell from a header's or the Channels list's `notifications` ({ state | current,
|
||||
// options }), with the current choice marked (selected; else the one the state or the
|
||||
// current label names), or null when it has no choices.
|
||||
function bellOf(n) {
|
||||
if (!n || !n.options || !n.options.length) return null;
|
||||
const options = n.options.map(o => ({ ...o }));
|
||||
if (!options.some(o => o.selected)) {
|
||||
const now = options.find(o => n.state && actions.bellMatches && actions.bellMatches(o.label, n.state))
|
||||
|| options.find(o => n.current && String(n.current).toLowerCase().includes(String(o.label).toLowerCase()));
|
||||
if (now) now.selected = true;
|
||||
}
|
||||
return { options, busy: false };
|
||||
}
|
||||
// A choice's icon (Material Symbols) from its label (YouTube's words: All / Personalized / None).
|
||||
const bellIcon = label => (/^all/i.test(label) ? 'notifications_active' : /none|off/i.test(label) ? 'notifications_off' : 'notifications');
|
||||
// The bell's menu (host.openMenu('action:bell')): YouTube's choices, the current one ticked.
|
||||
function menu(local) {
|
||||
if (local !== 'action:bell' || !bell) return null;
|
||||
return { note: tr('yt.bell.title'), items: bell.options.map(o => ({ id: o.params, text: o.label, on: !!o.selected, icons: [bellIcon(o.label)], disabled: bell.busy })) };
|
||||
}
|
||||
// Choose a bell setting; the tick moves only when YouTube's answer confirms it (or, with no
|
||||
// state in the answer, when it says OK).
|
||||
function chooseBell(params) {
|
||||
const b = bell, o = b && b.options.find(x => x.params === params), id = channelId;
|
||||
if (!o || o.selected || b.busy) return;
|
||||
b.busy = true; bump();
|
||||
Promise.resolve(actions.setBell(o)).then(res => {
|
||||
if (res && res.ok) b.options.forEach(x => { x.selected = x === o; });
|
||||
else console.warn('[streamxr] bell change failed:', res && res.error);
|
||||
}, err => console.warn('[streamxr] bell change failed:', err)).finally(() => { b.busy = false; if (id === channelId) bump(); });
|
||||
}
|
||||
|
||||
function event(ev) {
|
||||
if (!channelId) return;
|
||||
if (ev.type === 'action' && ev.value === 'bell') { if (bell) host.openMenu('action:bell'); return; }
|
||||
if (ev.type === 'menu' && ev.menu === 'action:bell') { chooseBell(ev.value); return; }
|
||||
// Searching: the search bar's controls and the typed keys.
|
||||
if (ev.type === 'search') { startSearch(); return; }
|
||||
if (search && !opened) {
|
||||
@@ -391,9 +346,6 @@
|
||||
stats: [h.handle, h.subscribers, h.videoCount].filter(Boolean).join(' · ') } : null,
|
||||
loadingTitle: tr('yt.channel.loading'),
|
||||
action: channelId ? { id: 'subscribe', look: BUTTON[sub].look, label: BUTTON[sub].key ? tr(BUTTON[sub].key) : '…' } : null,
|
||||
// The bell, beside Subscribed: its icon shows the current choice.
|
||||
iconAction: channelId && sub === 'subscribed' && bell ? { id: 'bell', look: bell.busy ? 'busy' : 'secondary',
|
||||
icon: bellIcon((bell.options.find(o => o.selected) || { label: '' }).label) } : null,
|
||||
tabs: tabs(), tab, pills: search ? [] : named(SORTS[tab] || []), pill: sort,
|
||||
search: !search && !!(header && header.tabs && header.tabs.search), // the channel can be searched
|
||||
field: search ? { id: 'query', text: search.text, placeholder: tr('yt.channel.searchIn', { name: (header && header.title) || tr('yt.channel.thisChannel') }),
|
||||
@@ -429,20 +381,7 @@
|
||||
host.open();
|
||||
}
|
||||
|
||||
// A channel picked elsewhere (the You panel's Channels list): show it and open the panel.
|
||||
// hint: { subscribed, notifications } from that list, tab (optional): the tab to open on
|
||||
// (e.g. 'posts'; the first one it has if it lacks that).
|
||||
function show(id, hint = {}) {
|
||||
if (id !== channelId) openChannel(id, hint.subscribed, hint.notifications);
|
||||
if (hint.tab && SORTS[hint.tab] && hint.tab !== tab) {
|
||||
closePost();
|
||||
tab = hint.tab; sort = firstSort(tab);
|
||||
if (header) startList(); else bump(); // (before the header arrives, its callback starts the list)
|
||||
}
|
||||
if (!host.isOpen()) host.open();
|
||||
}
|
||||
|
||||
return { model, event, menu, afterDraw, opened: openCurrent, openChannel, openCurrent, showCurrent, show, noteSubscribed, channelId: () => channelId };
|
||||
return { model, event, afterDraw, opened: openCurrent, openChannel, openCurrent, showCurrent, noteSubscribed, channelId: () => channelId };
|
||||
}
|
||||
|
||||
ns.ytVr = ns.ytVr || {};
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// YouTube's own buttons on the controls panel (the shell's "extras", vr-panel.js): Captions
|
||||
// (left: a menu of Off and the video's caption tracks; the chosen track's lines are drawn in
|
||||
// VR by the shell, captions-overlay.js), Save (right: a scrolling menu of Watch later and
|
||||
// VR by the shell, captions-overlay.js) and Save (right: a scrolling menu of Watch later and
|
||||
// your playlists, each ticked when the video is in it; a choice adds it or takes it out, and
|
||||
// the menu stays open), and Jump ahead (at the seek bar's end: YouTube Premium's jump to where
|
||||
// most viewers skip to, marked with the Diamond).
|
||||
// the menu stays open).
|
||||
//
|
||||
// const ex = ns.ytVr.extras.create(); one per VR session (provider.js session())
|
||||
// ex.state(st) -> { left: [captions], right: [save], seek: jump | null } every frame (st: { ad, noVideo, paused, live }: all
|
||||
// ex.state(st) -> { left: [captions], right: [save] } every frame (st: { ad, noVideo }: both
|
||||
// are greyed out during an ad or with no video playing; the provider's
|
||||
// state); the buttons as panel-ui.js / vr-panel.js read them
|
||||
// ex.extra(id, itemId) the shell's actions.extra: a button's menu opened (itemId null) or
|
||||
@@ -73,17 +72,10 @@
|
||||
menu: { items: list.map(o => ({ id: o.id, text: o.busy ? `${o.title}\n${tr('yt.save.busy')}` : o.title, on: o.saved, disabled: o.busy })),
|
||||
note: save.loading && !list.length ? tr('yt.loading') : save.note || null, scroll: true, width: 520, stay: true },
|
||||
};
|
||||
// Jump ahead (YouTube Premium), at the seek bar's end (where Skip ad goes during ads),
|
||||
// only while YouTube's own Jump ahead pill is on screen: the Diamond, its name,
|
||||
// fast-forward; greyed out while paused.
|
||||
const live = !!(st && st.live), none = !!(st && st.noVideo);
|
||||
const ja = live || none || !youtube.jumpAheadOffered() ? null : { id: 'jump', icon: 'fast_forward', label: tr('yt.jumpAhead'), mark: premiumMark(),
|
||||
disabled: !!(st && (st.paused || st.ad)) };
|
||||
return { left: [cc], right: [sv], seek: ja };
|
||||
return { left: [cc], right: [sv] };
|
||||
}
|
||||
|
||||
function extra(id, itemId) {
|
||||
if (id === 'jump') { youtube.jumpAhead(); return; }
|
||||
if (id === 'captions' && itemId != null) {
|
||||
const c = captions();
|
||||
youtube.setCaptions(itemId === 'off' ? null : c.tracks[+itemId] || null);
|
||||
@@ -100,11 +92,5 @@
|
||||
}
|
||||
|
||||
ns.ytVr = ns.ytVr || {};
|
||||
// YouTube Premium's mark: Material Symbols' Diamond, YouTube red when you have Premium, purple
|
||||
// (an upsell, like YouTube's own) when you don't. An icon spec for the shell (symbols.js drawIcon).
|
||||
const PREMIUM_RED = '#ff0033', UPSELL_PURPLE = '#a142f4';
|
||||
function premiumMark() { return { name: 'diamond', color: youtube.hasPremium() ? PREMIUM_RED : UPSELL_PURPLE, filled: true }; }
|
||||
|
||||
ns.ytVr.extras = { create };
|
||||
ns.ytVr.premiumMark = premiumMark;
|
||||
})(globalThis.__streamxr = globalThis.__streamxr || {});
|
||||
@@ -18,11 +18,7 @@
|
||||
// create(host) -> controller }.
|
||||
// host (given by vr-panel.js): { actions (the session's: provider + shell), style (this panel's style instance:
|
||||
// maxScroll(), shownScroll() ...), perPage (cards / rows per page), open(), close(),
|
||||
// isOpen() (this panel), closeMenu() (the controls panel's open menu), openMenu(control)
|
||||
// (a floating menu on one of this panel's controls, e.g. 'action:bell': its items from the
|
||||
// controller's menu(control) -> { items: [{ id, text, on, disabled, icons }], note, stay },
|
||||
// a choice arriving as { type: 'menu', value: item id, menu: control }), panel(id) (another
|
||||
// panel's controller), confirm(d) (ask
|
||||
// isOpen() (this panel), closeMenu() (the controls panel's open menu), confirm(d) (ask
|
||||
// in the shell's dialog: d = { title, text, confirm, cancel, danger } -> Promise of bool),
|
||||
// typeInto(on, opts) (opts.emojis: [{ text, image, label }], the panel's own emojis for the
|
||||
// keyboard's emoji pages; ask
|
||||
@@ -43,8 +39,6 @@
|
||||
// tick(frame) (optional) every frame, shown or not
|
||||
// afterDraw() (optional) after the style drew the model
|
||||
// opened() (optional) its toolbar item (or pill) opened the panel
|
||||
// menu(control) (optional) while a menu it opened (host.openMenu) is up, every
|
||||
// frame: what it lists (null closes it)
|
||||
// ...anything else the controls panel calls (e.g. channel.openChannel) }
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
@@ -63,25 +63,6 @@
|
||||
return [QUALITY_ICONS[q] || 'high_quality', ...(fps >= 50 ? ['60fps'] : [])];
|
||||
}
|
||||
|
||||
// YouTube Premium's mark (extras.js premiumMark: the Diamond, red with Premium, purple without).
|
||||
const diamond = () => ns.ytVr.premiumMark();
|
||||
|
||||
// The shell's quality state from youtube.quality(): the levels (YouTube's names and icons),
|
||||
// with Premium's enhanced-bitrate one ('premium', e.g. "1080p Premium") just above its plain
|
||||
// twin, marked with the Diamond and greyed out without Premium; it's the current one while
|
||||
// it plays.
|
||||
function qualityState(q) {
|
||||
const p = q.premium;
|
||||
const premiumLevel = p && { id: 'premium', label: p.label.replace(' ', '\n'), icons: [...qualityIcons(p.quality, q.labels).slice(0, 1), diamond()], disabled: !p.playable };
|
||||
const levels = [];
|
||||
for (const id of q.levels || []) {
|
||||
if (premiumLevel && id === p.quality) levels.push(premiumLevel);
|
||||
levels.push({ id, label: qualityName(id, q.labels), icons: qualityIcons(id, q.labels) });
|
||||
}
|
||||
if (p && p.on) return { current: 'premium', currentLabel: p.label, icons: premiumLevel.icons, levels };
|
||||
return { current: q.current, currentLabel: qualityName(q.current, q.labels), icons: qualityIcons(q.current, q.labels), levels };
|
||||
}
|
||||
|
||||
// Pick the format from player data (youtube.info(): projection[], spherical,
|
||||
// partialSpherical, title, keywords, video = "WxH"):
|
||||
// not marked spherical by YouTube -> cinema screen
|
||||
@@ -340,10 +321,6 @@
|
||||
ratePost: (post, what) => ns.youtubeChannel.ratePost(post, what),
|
||||
votePoll: (post, index, on) => ns.youtubeChannel.votePoll(post, index, on),
|
||||
setSubscribed,
|
||||
// A channel's notification bell: one of its notifications.options (youtube-subscriptions.js
|
||||
// setNotifications -> { ok, confirmed, state, error }), and which choice a state means.
|
||||
setBell: option => ns.youtubeSubscriptions.setNotifications(option),
|
||||
bellMatches: (label, state) => ns.youtubeSubscriptions.bellMatches(label, state),
|
||||
// Description panel: the transcript (opens and reads YouTube's transcript panel), the
|
||||
// comments ('top' | 'newest') and a thread's replies (pagers), rating a comment.
|
||||
transcript: () => ns.youtubeContent.transcript(),
|
||||
@@ -357,7 +334,6 @@
|
||||
search: query => ns.youtubeContent.search(query), // the Search panel
|
||||
suggest: text => ns.youtubeHome.suggestions(text), // and its suggestions
|
||||
browseSubscriptions: () => ns.youtubeSubscriptions.feed(),
|
||||
browseChannels: sort => ns.youtubeSubscriptions.channels(sort), // You: Channels (sort: one of p.sorts' texts)
|
||||
browseFeed: name => ns.youtubeHome.feedVideos(name),
|
||||
browsePlaylist: id => ns.youtubeContent.playlistVideos(id),
|
||||
myChannelId: () => ns.youtubeHome.myChannelId(), // You: Your channel
|
||||
@@ -382,14 +358,14 @@
|
||||
title, time: l ? l.current - l.start : youtube.currentTime(), duration: l ? l.end - l.start : youtube.duration(),
|
||||
live: l && liveLabel(l), paused: youtube.isPaused(),
|
||||
buffered: l || ad() ? null : youtube.bufferedEnd(), // the seek bar's loaded part (not on live streams or ads)
|
||||
quality: qualityState(q),
|
||||
quality: { current: q.current, currentLabel: qualityName(q.current, q.labels), icons: qualityIcons(q.current, q.labels), levels: (q.levels || []).map(id => ({ id, label: qualityName(id, q.labels), icons: qualityIcons(id, q.labels) })) },
|
||||
speed: youtube.speed(), speeds: youtube.speeds(), volume: youtube.volume(), chapters: chapters(), heatmap: l ? [] : heatmap(), ad: ad(),
|
||||
nowPlaying: nowPlaying({ title, channel, details, playlist: pl, subscribeUi: subscribeUi() }),
|
||||
// For YouTube's panels:
|
||||
channelId: ns.youtubeChannel.currentChannelId(), details, playlist: pl, description: description(),
|
||||
recommendations: page.recommendations(),
|
||||
playback: { loop: youtube.loop(), autoplay: !!youtube.autoplay() }, audio: audioTracks(),
|
||||
extras: extras.state({ ad: ad(), paused: youtube.isPaused(), live: !!l }),
|
||||
extras: extras.state({ ad: ad() }),
|
||||
liveChat: chatFor(l), // a live stream's chat, or a finished one's chat replay (live-chat.js)
|
||||
};
|
||||
// No video playing (VR started from the Home page...): what the panels show instead of
|
||||
|
||||
@@ -88,11 +88,6 @@
|
||||
'yt.browse.mychannelMissing': 'Couldn’t find your channel. Are you signed in on YouTube?',
|
||||
'yt.browse.subs': 'Subscriptions',
|
||||
'yt.browse.subsNote': 'New videos from the channels you subscribe to',
|
||||
'yt.browse.channels': 'Channels',
|
||||
'yt.browse.channelsNote': 'The channels you subscribe to, A to Z. Pick one to open it.',
|
||||
'yt.bell.title': 'Notifications',
|
||||
'yt.bell.line': 'Notifications: {name}',
|
||||
'yt.jumpAhead': 'Jump ahead',
|
||||
'yt.browse.history': 'History',
|
||||
'yt.browse.historyNote': 'Videos you’ve watched, newest first',
|
||||
'yt.browse.playlists': 'Playlists',
|
||||
|
||||
@@ -78,11 +78,6 @@
|
||||
'yt.browse.mychannelMissing': 'No se encontró tu canal. ¿Has iniciado sesión en YouTube?',
|
||||
'yt.browse.subs': 'Suscripciones',
|
||||
'yt.browse.subsNote': 'Vídeos nuevos de los canales a los que te suscribes',
|
||||
'yt.browse.channels': 'Canales',
|
||||
'yt.browse.channelsNote': 'Los canales a los que te suscribes, de la A a la Z. Elige uno para abrirlo.',
|
||||
'yt.bell.title': 'Notificaciones',
|
||||
'yt.bell.line': 'Notificaciones: {name}',
|
||||
'yt.jumpAhead': 'Saltar adelante',
|
||||
'yt.browse.history': 'Historial',
|
||||
'yt.browse.historyNote': 'Vídeos que has visto, los más recientes primero',
|
||||
'yt.browse.playlists': 'Listas',
|
||||
|
||||
@@ -84,8 +84,7 @@
|
||||
thumb: bestImage(g.thumbnailRenderer) };
|
||||
}
|
||||
|
||||
// A post (backstagePostRenderer / postRenderer): { id, text, author, channelId (the
|
||||
// author's, '' if not given), avatar, published, likes,
|
||||
// A post (backstagePostRenderer / postRenderer): { id, text, author, published, likes,
|
||||
// images: [urls], video: item | null, comments (the count, "882"), commentsParams (its
|
||||
// own page, for postComments), likeState ('liked' | 'disliked' | 'none': yours),
|
||||
// actions: { like, unlike, dislike, undislike } (endpoints for ratePost; null signed out),
|
||||
@@ -110,7 +109,6 @@
|
||||
dislike: usable(dislike.defaultServiceEndpoint), undislike: usable(dislike.toggledServiceEndpoint) };
|
||||
return {
|
||||
id: p.postId, text: strOf(p.contentText), author: strOf(p.authorText),
|
||||
channelId: dig(p, 'authorEndpoint', 'browseEndpoint', 'browseId') || '', avatar: bestImage(p.authorThumbnail),
|
||||
published: strOf(p.publishedTimeText), likes: strOf(p.voteCount),
|
||||
images, video: att.videoRenderer ? D.fromCompact(att.videoRenderer) : null,
|
||||
comments: strOf(reply.text), commentsParams: dig(reply, 'navigationEndpoint', 'browseEndpoint', 'params') || '',
|
||||
@@ -164,7 +162,6 @@
|
||||
// channel(id) -> Promise of { id, title, badge, handle, subscribers, videoCount,
|
||||
// description (short), avatar, banner, links (e.g. "x.com/somechannel and 4 more links"),
|
||||
// url, rssUrl, keywords, isFamilySafe, subscribed (true/false signed in, else null),
|
||||
// notifications (the bell: { state, options } or null, see bellOf),
|
||||
// tabs: { videos: params, ... } (only the tabs this channel has), aboutToken }. Cached
|
||||
// for 5 minutes.
|
||||
const headerCache = new Map(); // id -> { at, promise }
|
||||
@@ -207,45 +204,9 @@
|
||||
// Signed in: YouTube's saved subscription state (the same subscriptionStateEntity a
|
||||
// Subscribe answer carries); null while signed out or when it isn't sent.
|
||||
subscribed: (() => { const e = findKeyAll(r.frameworkUpdates, 'subscriptionStateEntity')[0]; return e && e.subscribed != null ? !!e.subscribed : null; })(),
|
||||
notifications: bellOf(r),
|
||||
};
|
||||
}
|
||||
|
||||
// The header's notification bell, signed in and subscribed: { state ('ALL', 'OCCASIONAL',
|
||||
// 'OFF' ...: the end of SUBSCRIPTION_NOTIFICATION_STATE_*, or ''), options: [{ label,
|
||||
// params, selected }] } (the choices YouTube's own bell menu sends, as in the Manage list's
|
||||
// notificationOptions), or null when the header has none. UNVERIFIED (written signed out):
|
||||
// each choice is found as a modifyChannelNotificationPreferenceEndpoint inside the header,
|
||||
// named by the nearest enclosing item with a title or text; the state is the
|
||||
// subscriptionNotificationStateEntity the header's button points to (by its store key).
|
||||
function bellOf(r) {
|
||||
const options = [], seen = new Set();
|
||||
const walk = (node, stack, depth) => {
|
||||
if (!node || typeof node !== 'object' || depth > 40) return;
|
||||
if (Array.isArray(node)) { for (const x of node) walk(x, stack, depth + 1); return; }
|
||||
const ep = node.modifyChannelNotificationPreferenceEndpoint;
|
||||
if (ep && ep.params) {
|
||||
if (seen.has(ep.params)) return;
|
||||
seen.add(ep.params);
|
||||
const item = [...stack].reverse().find(a => strOf(a.title) || strOf(a.text));
|
||||
if (item) options.push({ label: strOf(item.title) || strOf(item.text), params: ep.params, selected: !!(item.isSelected || item.selected) });
|
||||
return;
|
||||
}
|
||||
stack.push(node);
|
||||
for (const k of Object.keys(node)) walk(node[k], stack, depth + 1);
|
||||
stack.pop();
|
||||
};
|
||||
walk(r.header, [], 0);
|
||||
if (!options.length) return null;
|
||||
const key = findKey(r.header, 'notificationStateEntityStoreKey');
|
||||
const ents = (dig(r, 'frameworkUpdates', 'entityBatchUpdate', 'mutations') || [])
|
||||
.filter(m => findKey(m.payload, 'subscriptionNotificationStateEntity'));
|
||||
const ent = ents.find(m => key && m.entityKey === key) || (ents.length === 1 ? ents[0] : null);
|
||||
const state = String(dig(findKey(ent && ent.payload, 'subscriptionNotificationStateEntity'), 'state') || '')
|
||||
.replace(/^SUBSCRIPTION_NOTIFICATION_STATE_/, '');
|
||||
return { state, options };
|
||||
}
|
||||
|
||||
// The browse params for one of a channel's tabs (its own, or the usual ones).
|
||||
const tabParams = async (channelId, name) => (await channel(channelId)).tabs[name] || TAB_PARAMS[name];
|
||||
|
||||
|
||||
@@ -180,15 +180,12 @@
|
||||
}
|
||||
|
||||
// In-page navigation through YouTube's app (no page reload). Returns false if unavailable.
|
||||
// The event is sent from the page manager (inside the app), not the app element itself:
|
||||
// sent from ytd-app it's ignored on some pages (a channel page, checked 2026-10-17), while
|
||||
// from ytd-page-manager it works there and from the watch page alike.
|
||||
function navigate(url, watchEndpoint) {
|
||||
const from = document.querySelector('ytd-page-manager') || document.querySelector('ytd-app');
|
||||
if (!from) return false;
|
||||
const app = document.querySelector('ytd-app');
|
||||
if (!app) return false;
|
||||
const endpoint = { commandMetadata: { webCommandMetadata: { url, webPageType: 'WEB_PAGE_TYPE_WATCH', rootVe: 3832 } }, watchEndpoint };
|
||||
try {
|
||||
from.dispatchEvent(new CustomEvent('yt-navigate', { bubbles: true, composed: true, detail: { endpoint } }));
|
||||
app.dispatchEvent(new CustomEvent('yt-navigate', { bubbles: true, composed: true, detail: { endpoint } }));
|
||||
return true;
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
@@ -55,83 +55,30 @@
|
||||
// Video quality. YouTube ids: 'hd2160', 'hd1440', 'hd1080', 'hd720', 'large' (480p),
|
||||
// 'medium' (360p), 'small', 'tiny', 'auto'. labels: YouTube's own name for each id where
|
||||
// the player gives one (getAvailableQualityData's qualityLabel, e.g. "1080p60" on a 60 fps
|
||||
// video; Premium's extra entry is kept apart as `premium`).
|
||||
// premium: YouTube Premium's enhanced-bitrate quality, or null when the video has none:
|
||||
// { quality (its id, 'hd1080'), label ("1080p Premium"), formatId, playable (false: you
|
||||
// don't have Premium), on (playing now) }. Seen signed in with Premium (2026-10-17): an
|
||||
// entry with paygatedQualityDetails and its own formatId ("721") beside the plain
|
||||
// 1080p's ("399"); getPlaybackQualityLabel() names the one playing.
|
||||
// formats: the plain entries' formatIds by id, where the player gives one.
|
||||
// video; Premium's extra "1080p Premium" entry is skipped in favour of the plain one).
|
||||
function quality() {
|
||||
try {
|
||||
const p = player();
|
||||
const levels = (p && p.getAvailableQualityLevels && p.getAvailableQualityLevels()) || [];
|
||||
const current = (p && p.getPlaybackQuality && p.getPlaybackQuality()) || null;
|
||||
const labels = {}, formats = {};
|
||||
let premium = null;
|
||||
const labels = {};
|
||||
const data = (p && p.getAvailableQualityData && p.getAvailableQualityData()) || [];
|
||||
for (const d of data) {
|
||||
if (!d || !d.quality || !d.qualityLabel) continue;
|
||||
if (d.paygatedQualityDetails || /premium/i.test(d.qualityLabel)) {
|
||||
if (!premium) premium = { quality: d.quality, label: d.qualityLabel, formatId: d.formatId || null, playable: d.isPlayable !== false, on: false };
|
||||
continue;
|
||||
}
|
||||
if (!labels[d.quality]) { labels[d.quality] = d.qualityLabel; if (d.formatId) formats[d.quality] = d.formatId; }
|
||||
const premium = !!d.paygatedQualityDetails || /premium/i.test(d.qualityLabel);
|
||||
if (!labels[d.quality] || !premium) labels[d.quality] = premium ? labels[d.quality] || d.qualityLabel : d.qualityLabel;
|
||||
}
|
||||
if (premium) {
|
||||
const now = (p.getPlaybackQualityLabel && p.getPlaybackQualityLabel()) || '';
|
||||
premium.on = current === premium.quality && now === premium.label;
|
||||
}
|
||||
return { current, levels, labels, formats, premium };
|
||||
} catch (_) { return { current: null, levels: [], labels: {}, formats: {}, premium: null }; }
|
||||
return { current, levels, labels };
|
||||
} catch (_) { return { current: null, levels: [], labels: {} }; }
|
||||
}
|
||||
|
||||
// Choose a quality (one of the ids above, or 'premium': Premium's enhanced-bitrate one).
|
||||
// Where the player gives a format id it's passed too (it tells the plain 1080p and 1080p
|
||||
// Premium apart). YouTube has changed which method works over time, so both are tried;
|
||||
// whichever the player lacks is simply skipped.
|
||||
// Choose a quality (one of the ids above). YouTube has changed which method works over
|
||||
// time, so both are tried; whichever the player lacks is simply skipped.
|
||||
function setQuality(level) {
|
||||
const p = player();
|
||||
if (!p) return;
|
||||
const q = quality();
|
||||
let id = level, formatId = q.formats[level];
|
||||
if (level === 'premium') {
|
||||
if (!q.premium || !q.premium.playable) return;
|
||||
id = q.premium.quality; formatId = q.premium.formatId;
|
||||
}
|
||||
try { if (p.setPlaybackQualityRange) p.setPlaybackQualityRange(id, id, formatId || undefined); } catch (_) { /* older player */ }
|
||||
if (level !== 'premium') try { if (p.setPlaybackQuality) p.setPlaybackQuality(id); } catch (_) { /* newer player */ }
|
||||
}
|
||||
|
||||
// YouTube Premium's Jump ahead: to the point most viewers skip to. YouTube offers it as a pill
|
||||
// in the player's "timely actions" overlay at the moments it applies (seen signed in with
|
||||
// Premium, 2026-10-17: ytw-timely-action-view-model > button, its text "Jump ahead"), and a
|
||||
// script's click on that pill jumps (17 s -> 124 s). The pill is found by its text, so this
|
||||
// expects YouTube in English, like the other readers that match YouTube's words.
|
||||
// jumpAheadOffered() -> is the pill on screen now? jumpAhead() clicks it (if it is).
|
||||
function jumpAheadButton() {
|
||||
const p = player();
|
||||
if (!p) return null;
|
||||
for (const b of p.querySelectorAll('.ytp-timely-actions-overlay button, ytw-timely-action-view-model button')) {
|
||||
if (b.getClientRects().length && /jump ahead/i.test((b.getAttribute('aria-label') || '') + ' ' + (b.textContent || ''))) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const jumpAheadOffered = () => !!jumpAheadButton();
|
||||
function jumpAhead() {
|
||||
const b = jumpAheadButton();
|
||||
if (b) b.click();
|
||||
}
|
||||
|
||||
// Does the signed-in account have YouTube Premium? The page's top-bar logo is YouTube
|
||||
// Premium's (seen signed in with Premium, 2026-10-17: topbarLogoRenderer iconType
|
||||
// 'YOUTUBE_PREMIUM_LOGO'; 'YOUTUBE_LOGO' otherwise).
|
||||
function hasPremium() {
|
||||
try {
|
||||
const logo = window.ytInitialData && window.ytInitialData.topbar && window.ytInitialData.topbar.desktopTopbarRenderer
|
||||
&& window.ytInitialData.topbar.desktopTopbarRenderer.logo;
|
||||
return JSON.stringify(logo || {}).includes('YOUTUBE_PREMIUM_LOGO');
|
||||
} catch (_) { return false; }
|
||||
try { if (p.setPlaybackQualityRange) p.setPlaybackQualityRange(level, level); } catch (_) { /* older player */ }
|
||||
try { if (p.setPlaybackQuality) p.setPlaybackQuality(level); } catch (_) { /* newer player */ }
|
||||
}
|
||||
|
||||
// Name of the channel that posted the video.
|
||||
@@ -403,7 +350,7 @@
|
||||
}
|
||||
|
||||
ns.youtube = {
|
||||
player, video, call, toggle, seek, seekTo, isPaused, currentTime, duration, title, channel, quality, setQuality, hasPremium, jumpAhead, jumpAheadOffered, info,
|
||||
player, video, call, toggle, seek, seekTo, isPaused, currentTime, duration, title, channel, quality, setQuality, info,
|
||||
speeds, speed, setSpeed, volume, setVolume, setMuted,
|
||||
captions, setCaptions, captionLines, audioTracks, setAudioTrack,
|
||||
loop, setLoop, autoplay, setAutoplay, playlistPosition, nextVideo, previousVideo, playAt,
|
||||
|
||||
@@ -126,84 +126,19 @@
|
||||
return { current, options };
|
||||
}
|
||||
|
||||
// The Manage page's sort choices (YouTube's "A-Z" dropdown: Most relevant, New activity,
|
||||
// A-Z): [{ text, selected, token }]. Seen on the signed-in page (2026-10-17): each choice is a
|
||||
// listItemViewModel whose entityKey is 'channel_list_page_sort_chip_entity:<its text>';
|
||||
// tapping it updates a chipEntity (the dropdown's label) and sends a browse continuation
|
||||
// that reloads the list in that order. The chosen one is the chipEntity's text (or a choice
|
||||
// marked isSelected).
|
||||
const SORT_KEY = 'channel_list_page_sort_chip_entity:';
|
||||
// Every value under `key` anywhere in `root`, however deep (the sort menu sits deeper than
|
||||
// findKeyAll's 20 levels, in the dropdown's sheet).
|
||||
function deepFind(root, key, out, depth = 0) {
|
||||
if (!root || typeof root !== 'object' || depth > 80) return out;
|
||||
if (Array.isArray(root)) { for (const x of root) deepFind(x, key, out, depth + 1); return out; }
|
||||
for (const k of Object.keys(root)) { if (k === key) out.push(root[k]); else deepFind(root[k], key, out, depth + 1); }
|
||||
return out;
|
||||
}
|
||||
function sortsOf(r) {
|
||||
const items = deepFind(r, 'listItemViewModel', []).filter(it => String(it.entityKey || '').startsWith(SORT_KEY));
|
||||
const chips = (dig(r, 'frameworkUpdates', 'entityBatchUpdate', 'mutations') || [])
|
||||
.map(m => findKey(m.payload, 'chipEntity')).filter(Boolean);
|
||||
const out = [];
|
||||
for (const it of items) {
|
||||
const text = strOf(it.title) || String(it.entityKey).slice(SORT_KEY.length);
|
||||
if (!text || out.some(x => x.text === text)) continue;
|
||||
const tap = dig(it, 'rendererContext', 'commandContext', 'onTap');
|
||||
// The chipEntity this choice updates, and what that entity says now (the current choice).
|
||||
const key = dig(findKey(tap, 'chipEntity'), 'key');
|
||||
const now = chips.find(c => key && c.key === key);
|
||||
out.push({ text, token: dig(findKey(tap, 'continuationCommand'), 'token') || null,
|
||||
selected: !!(it.isSelected || it.selected || (now && strOf(now.text) === text)) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The channels on a Manage page, each with `group`: the title of the shelf it's in (YouTube
|
||||
// splits the list into shelves, e.g. "Purchased" for channels you're a member of, then
|
||||
// "Subscribed"; seen signed in, 2026-10-17), '' outside any, and `aside` (below).
|
||||
function channelsOn(r) {
|
||||
const out = [];
|
||||
const shelves = C.findKeyAll(r, 'shelfRenderer');
|
||||
for (const s of shelves) {
|
||||
const group = strOf(s.title);
|
||||
for (const c of C.findKeyAll(s, 'channelRenderer').map(fromChannel).filter(Boolean)) out.push({ ...c, group });
|
||||
}
|
||||
if (!shelves.length) out.push(...C.findKeyAll(r, 'channelRenderer').map(fromChannel).filter(Boolean).map(c => ({ ...c, group: '' })));
|
||||
// `aside`: the group's title on the channels of every shelf but the last (YouTube's own
|
||||
// list comes after them: "Subscribed"), so they can be told apart; '' on the rest.
|
||||
const last = out.length ? out[out.length - 1].group : '';
|
||||
for (const c of out) c.aside = c.group && c.group !== last ? c.group : '';
|
||||
return out;
|
||||
}
|
||||
|
||||
// channels(sort) -> pager over the subscribed channels (the "Manage" page, browse id
|
||||
// FEchannels). sort: one of the page's sort choices by its text (e.g. 'A-Z'; default: the
|
||||
// page's own order). After the first page p.sorts = [{ text, selected }] lists the real
|
||||
// choices. p.message is the sign-in note while signed out.
|
||||
function channels(sort) {
|
||||
// channels() -> pager over the subscribed channels (the "Manage" page, browse id
|
||||
// FEchannels), A-Z as YouTube lists them. p.message is the sign-in note while signed out.
|
||||
function channels() {
|
||||
const p = D.pager(async token => {
|
||||
let r;
|
||||
if (token) r = await D.innertube('browse', { continuation: token });
|
||||
else {
|
||||
r = await D.innertube('browse', { browseId: 'FEchannels' });
|
||||
p.message = signInMessage(r);
|
||||
const sorts = sortsOf(r);
|
||||
p.sorts = sorts.map(({ text, selected }) => ({ text, selected }));
|
||||
const s = sort && sorts.find(x => x.token && x.text.toLowerCase() === String(sort).toLowerCase() && !x.selected);
|
||||
if (s) {
|
||||
r = await D.innertube('browse', { continuation: s.token });
|
||||
p.sorts = p.sorts.map(x => ({ text: x.text, selected: x.text === s.text }));
|
||||
}
|
||||
}
|
||||
const r = await D.innertube('browse', token ? { continuation: token } : { browseId: 'FEchannels' });
|
||||
if (!token) p.message = signInMessage(r);
|
||||
// Everything in this list is subscribed, whatever its button says (some only carry a
|
||||
// stub that says false). notifications.options is [] when YouTube offers no bell.
|
||||
const items = channelsOn(r).map(c => ({ ...c, subscribed: true }));
|
||||
// (Its "load more" item can sit at the end of the last of several sections.)
|
||||
const more = C.findKeyAll(r, 'continuationItemRenderer').pop();
|
||||
return { items, token: C.pageToken(r) || (more ? D.continuationOf({ continuationItemRenderer: more }) : null) };
|
||||
const items = C.findKeyAll(r, 'channelRenderer').map(fromChannel).filter(Boolean)
|
||||
.map(c => ({ ...c, subscribed: true }));
|
||||
return { items, token: C.pageToken(r) };
|
||||
}, null);
|
||||
p.message = ''; p.sorts = [];
|
||||
p.message = '';
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -251,9 +186,6 @@
|
||||
// Which reported states mean each menu label. Seen signed in: All = ALL, Personalized =
|
||||
// OCCASIONAL (0.20.5). None wasn't seen yet, so close variants are accepted.
|
||||
const BELL_STATES = { all: /^ALL$/, personalized: /PERSONALI[SZ]ED|DEFAULT|OCCASIONAL/, none: /OFF|NONE|NO_NOTIFICATIONS/ };
|
||||
// Does a bell state ('ALL', 'OCCASIONAL' ...) mean the menu choice called `label`? (false
|
||||
// for a label it doesn't know).
|
||||
const bellMatches = (label, state) => { const re = BELL_STATES[String(label).toLowerCase()]; return !!(re && state && re.test(state)); };
|
||||
|
||||
// setNotifications(option) -> Promise of { ok, confirmed, state?, error? }: choose a bell
|
||||
// setting, where `option` is one of a channel's notifications.options (from channels()).
|
||||
@@ -272,5 +204,5 @@
|
||||
} catch (err) { return { ok: false, confirmed: false, error: String(err && err.message || err) }; }
|
||||
}
|
||||
|
||||
ns.youtubeSubscriptions = { feed, channels, subscribe, unsubscribe, setNotifications, bellMatches, fromChannel, checkSignIn, checkAccount, answeredSignedIn, signedIn: D.signedIn };
|
||||
ns.youtubeSubscriptions = { feed, channels, subscribe, unsubscribe, setNotifications, fromChannel, checkSignIn, checkAccount, answeredSignedIn, signedIn: D.signedIn };
|
||||
})(globalThis.__streamxr = globalThis.__streamxr || {});
|
||||
@@ -85,9 +85,7 @@
|
||||
// as uSize), d its distance
|
||||
// outside a rounded rectangle of corner radius uRadius (Inigo Quilez's sdRoundBox); the
|
||||
// last 4 mm inside the edge fade out (alpha, blended over what's behind without touching
|
||||
// the frame's alpha: see draw), and outside it's discarded. The video is read at this eye's
|
||||
// part of the frame across (uUvX: scale, offset; the whole frame, or a 3D side-by-side
|
||||
// frame's half).
|
||||
// the frame's alpha: see draw), and outside it's discarded.
|
||||
const VS = `
|
||||
attribute vec2 aCorner; // -0.5..0.5
|
||||
uniform mat4 uProj, uView, uModel; uniform vec2 uSize; uniform float uBend;
|
||||
@@ -103,14 +101,14 @@
|
||||
gl_Position = uProj * uView * uModel * vec4(p, 1.0);
|
||||
}`;
|
||||
const FS = `
|
||||
precision mediump float; uniform sampler2D uTex; uniform vec2 uScreen, uUvX; uniform float uRadius; varying vec2 vUv;
|
||||
precision mediump float; uniform sampler2D uTex; uniform vec2 uScreen; uniform float uRadius; varying vec2 vUv;
|
||||
void main() {
|
||||
vec2 p = (vUv - 0.5) * uScreen;
|
||||
vec2 q = abs(p) - uScreen * 0.5 + uRadius;
|
||||
float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - uRadius;
|
||||
float a = 1.0 - smoothstep(-0.004, 0.0, d);
|
||||
if (a <= 0.0) discard;
|
||||
gl_FragColor = vec4(texture2D(uTex, vec2(vUv.x * uUvX.x + uUvX.y, vUv.y)).rgb, a);
|
||||
gl_FragColor = vec4(texture2D(uTex, vUv).rgb, a);
|
||||
}`;
|
||||
|
||||
// The glow (WebGL 2 shaders, GLSL ES 3.00, for textureLod): the same strip, bigger (uSize,
|
||||
@@ -122,8 +120,7 @@
|
||||
// texture coordinate clamped to the picture) from the mipmap level about uDetail texels
|
||||
// wide (blurred; a level further up further out: chosen here rather than by the GPU, which
|
||||
// would pick the sharpest level where the clamped coordinate doesn't change), fading out to
|
||||
// nothing at uReach metres. Added to what's behind (light), at uStrength. Read at this eye's
|
||||
// part of the frame across (uUvX), as FS.
|
||||
// nothing at uReach metres. Added to what's behind (light), at uStrength.
|
||||
const GLOW_VS = `#version 300 es
|
||||
in vec2 aCorner;
|
||||
uniform mat4 uProj, uView, uModel; uniform vec2 uSize; uniform float uBend;
|
||||
@@ -140,7 +137,7 @@
|
||||
}`;
|
||||
const GLOW_FS = `#version 300 es
|
||||
precision mediump float;
|
||||
uniform sampler2D uTex; uniform vec2 uGlow, uScreen, uUvX; uniform float uRadius, uReach, uDetail, uStrength;
|
||||
uniform sampler2D uTex; uniform vec2 uGlow, uScreen; uniform float uRadius, uReach, uDetail, uStrength;
|
||||
in vec2 vUv;
|
||||
out vec4 outColor;
|
||||
void main() {
|
||||
@@ -152,7 +149,6 @@
|
||||
float a = uStrength * (1.0 - t) * (1.0 - t);
|
||||
if (a <= 0.002) discard;
|
||||
vec2 uv = clamp(p / uScreen + 0.5, 0.0, 1.0);
|
||||
uv.x = uv.x * uUvX.x + uUvX.y;
|
||||
float lod = log2(max(1.0, float(textureSize(uTex, 0).x) / uDetail)) + t * 1.5;
|
||||
outColor = vec4(textureLod(uTex, uv, lod).rgb, a);
|
||||
}`;
|
||||
@@ -205,20 +201,18 @@
|
||||
return new Float32Array(out);
|
||||
}
|
||||
|
||||
// Build the cinema screen renderer for format `fmt` ('flat', or 'flatsbs': 3D side by side,
|
||||
// each eye its half of the frame). Returns { upload, draw, place, dispose, interaction,
|
||||
// Build the cinema screen renderer. Returns { upload, draw, place, dispose, interaction,
|
||||
// pauseAnchor, distance, setDistance, setCurve }: the same drawing interface as the video
|
||||
// sphere, plus `interaction` (the laser-grabbable handles), `pauseAnchor` (where the pause
|
||||
// symbol goes) and the Settings controls.
|
||||
function createCinemaScreen(gl, fmt = 'flat') {
|
||||
const spans = ns.formats.eyeSpans(fmt); // which part of the frame each eye sees (3D side by side: its half)
|
||||
function createCinemaScreen(gl) {
|
||||
const prog = G.createProgram(gl, VS, FS, 'cinema screen');
|
||||
const hprog = G.createProgram(gl, VS, HANDLE_FS, 'cinema handles');
|
||||
const isGL2 = typeof WebGL2RenderingContext !== 'undefined' && gl instanceof WebGL2RenderingContext;
|
||||
const gprog = isGL2 ? G.createProgram(gl, GLOW_VS, GLOW_FS, 'cinema glow') : null; // (no glow on WebGL 1)
|
||||
const U = G.uniforms(gl, prog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', tex: 'uTex', screen: 'uScreen', radius: 'uRadius', uvX: 'uUvX' });
|
||||
const U = G.uniforms(gl, prog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', tex: 'uTex', screen: 'uScreen', radius: 'uRadius' });
|
||||
const GU = gprog && G.uniforms(gl, gprog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', tex: 'uTex', glow: 'uGlow',
|
||||
screen: 'uScreen', radius: 'uRadius', reach: 'uReach', detail: 'uDetail', strength: 'uStrength', uvX: 'uUvX' });
|
||||
screen: 'uScreen', radius: 'uRadius', reach: 'uReach', detail: 'uDetail', strength: 'uStrength' });
|
||||
const HU = G.uniforms(gl, hprog, { proj: 'uProj', view: 'uView', model: 'uModel', size: 'uSize', bend: 'uBend', shape: 'uShape', arc: 'uArc', color: 'uColor', alpha: 'uAlpha' });
|
||||
const vaos = G.vertexArrays(gl);
|
||||
const corners = new Float32Array([-0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5]);
|
||||
@@ -243,12 +237,6 @@
|
||||
const prefs = loadPrefs();
|
||||
let width = clampWidth(prefs.width || SETTINGS.width);
|
||||
let aspect = 16 / 9;
|
||||
// The screen's shape from the frame's (width / height): 3D side by side, one eye's picture:
|
||||
// half the frame when it's two full pictures (wider than 2.5:1, e.g. 32:9), else the frame
|
||||
// itself (half side by side: each eye squeezed into half of a 16:9 frame, shown at 16:9).
|
||||
const eyeAspect = frame => (ns.formats.isSbs(fmt) && frame > 2.5 ? frame / 2 : frame);
|
||||
// This eye's part of the frame across ([scale, offset]; a headset that names no eye: the left).
|
||||
const eyeSpan = view => (view.eye === 'right' ? spans.right : spans.left);
|
||||
let model = M.translation(0, 0, -SETTINGS.distance); // centre + orientation (+Z faces you)
|
||||
let dist = clampDistance(prefs.distance || SETTINGS.distance);
|
||||
let curve = curvePrefs(); // { on, amount }
|
||||
@@ -334,7 +322,7 @@
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);
|
||||
mipped = G.videoMipmaps(gl, ns.vrTokens.glass.on || glow.on);
|
||||
if (video.videoWidth && video.videoHeight) aspect = eyeAspect(video.videoWidth / video.videoHeight);
|
||||
if (video.videoWidth && video.videoHeight) aspect = video.videoWidth / video.videoHeight;
|
||||
}
|
||||
|
||||
// What's behind the panels, for the glass look (vr-panel/panel-pass.js): the video's
|
||||
@@ -348,7 +336,7 @@
|
||||
if (!mipped) return null;
|
||||
const g = gprog && glow.on && glow.strength > 0
|
||||
? { strength: glow.strength, reach: height() * glow.reach, radius: width * SETTINGS.cornerRadius } : null;
|
||||
return { kind: 'screen', tex, centre: centre(), right: axis(0), up: axis(1), normal: axis(2), size: [width, height()], bend: bend(), glow: g, uvX: spans };
|
||||
return { kind: 'screen', tex, centre: centre(), right: axis(0), up: axis(1), normal: axis(2), size: [width, height()], bend: bend(), glow: g };
|
||||
}
|
||||
|
||||
// The handles: { id, frame (a point on the screen's centre line, see edgeFrame), x, y
|
||||
@@ -421,7 +409,6 @@
|
||||
gl.uniform1f(GU.reach, reach);
|
||||
gl.uniform1f(GU.detail, SETTINGS.glowDetail);
|
||||
gl.uniform1f(GU.strength, glow.strength);
|
||||
gl.uniform2fv(GU.uvX, eyeSpan(view));
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE, gl.ZERO, gl.ONE);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, SETTINGS.columns * 6);
|
||||
@@ -447,7 +434,6 @@
|
||||
gl.uniform2f(U.size, width, height());
|
||||
gl.uniform2f(U.screen, width, height());
|
||||
gl.uniform1f(U.radius, width * SETTINGS.cornerRadius);
|
||||
gl.uniform2fv(U.uvX, eyeSpan(view));
|
||||
gl.uniform1f(U.bend, b);
|
||||
gl.uniformMatrix4fv(U.proj, false, view.projectionMatrix);
|
||||
gl.uniformMatrix4fv(U.view, false, view.transform.inverse.matrix);
|
||||
@@ -581,7 +567,7 @@
|
||||
|
||||
// The right renderer for a format: cinema screen for flat videos, sphere for the rest.
|
||||
function createVideoView(gl, fmt) {
|
||||
return ns.formats.isFlat(fmt) ? createCinemaScreen(gl, fmt) : ns.videoSphere.createVideoSphere(gl, fmt);
|
||||
return ns.formats.isFlat(fmt) ? createCinemaScreen(gl) : ns.videoSphere.createVideoSphere(gl, fmt);
|
||||
}
|
||||
|
||||
// The global reset: forget the remembered size, placement and curve.
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
const { formats, gl: G } = ns;
|
||||
|
||||
// Vertex shader: passes the vertex position along as the view direction (vDir) for the
|
||||
// cubemap lookup, and applies uUvXform (scale xy, offset zw) to the texture coordinates:
|
||||
// [1,1,0,0] for the whole frame, or one eye's half of a side-by-side 3D frame
|
||||
// (formats.eyeSpans).
|
||||
// cubemap lookup, and applies uUvXform (scale xy, offset zw) to the texture coordinates;
|
||||
// it's [1,1,0,0] ("whole frame") now that 3D layouts are gone, but it's where per-eye
|
||||
// cropping would plug in.
|
||||
const VS = `
|
||||
attribute vec3 aPos; attribute vec2 aUv;
|
||||
uniform mat4 uProj, uView, uModel; uniform vec4 uUvXform;
|
||||
@@ -97,7 +97,6 @@
|
||||
const vao = vaos ? vaos.create() : null;
|
||||
if (vaos) vaos.bind(vao);
|
||||
const mesh = buildSphere(formats.longitudeSpan(fmt));
|
||||
const spans = formats.eyeSpans(fmt); // which part of the frame each eye sees
|
||||
const buffers = [
|
||||
G.floatAttribute(gl, prog, 'aPos', mesh.pos, 3),
|
||||
G.floatAttribute(gl, prog, 'aUv', mesh.uv, 2),
|
||||
@@ -118,14 +117,13 @@
|
||||
|
||||
// What's behind the panels, for the glass look (vr-panel/panel-pass.js): the video's
|
||||
// texture and how it's mapped ('sphere': equirectangular over `span` radians of
|
||||
// longitude, `uvX` each eye's part of the frame across: { left, right }; 'eac': the
|
||||
// cubemap), turned by `yaw`. Builds the mipmaps at once if the glass was just turned on.
|
||||
// null while the glass is off.
|
||||
// longitude; 'eac': the cubemap), turned by `yaw`. Builds the mipmaps at once if the
|
||||
// glass was just turned on. null while the glass is off.
|
||||
function backdrop() {
|
||||
if (!ns.vrTokens.glass.on) return null;
|
||||
if (!mipped) { gl.bindTexture(gl.TEXTURE_2D, tex); mipped = G.videoMipmaps(gl, true); }
|
||||
if (!mipped) return null;
|
||||
return { kind: formats.projectionMode(fmt) > 0.5 ? 'eac' : 'sphere', tex, span: formats.longitudeSpan(fmt), uvX: spans, yaw: lastYaw };
|
||||
return { kind: formats.projectionMode(fmt) > 0.5 ? 'eac' : 'sphere', tex, span: formats.longitudeSpan(fmt), yaw: lastYaw };
|
||||
}
|
||||
|
||||
// The video is "at infinity": only the head's rotation matters, not its position.
|
||||
@@ -149,10 +147,7 @@
|
||||
m[12] = m[13] = m[14] = 0; // drop the head's position, keep only its rotation
|
||||
gl.uniformMatrix4fv(U.proj, false, view.projectionMatrix);
|
||||
gl.uniformMatrix4fv(U.view, false, m);
|
||||
// This eye's part of the frame (side by side: its half; a headset that names no eye
|
||||
// gets the left one).
|
||||
const [sx, ox] = view.eye === 'right' ? spans.right : spans.left;
|
||||
gl.uniform4fv(U.xf, [sx, 1, ox, 0]);
|
||||
gl.uniform4fv(U.xf, [1, 1, 0, 0]); // whole frame for both eyes (YouTube sends one eye)
|
||||
gl.drawElements(gl.TRIANGLES, mesh.idx.length, gl.UNSIGNED_SHORT, 0);
|
||||
}
|
||||
if (vaos) vaos.bind(null);
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
// both worlds (the panel lists the formats; the renderer needs the projection). Which
|
||||
// format a video is, is decided by the site's provider (detectFormat).
|
||||
//
|
||||
// 3D: most 3D videos reach the desktop as one eye only (shown as mono); some arrive with both
|
||||
// eyes side by side in one frame, which '180sbs' (VR180) and 'flatsbs' (the cinema screen)
|
||||
// give each eye its half of. Nothing in the site's data marks those, so they're picked by hand
|
||||
// (docs/youtube.md, "Formats and VR180").
|
||||
// There are no 3D formats: on desktop YouTube sends one eye only, even for 3D videos.
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
// The six formats. The ids are used everywhere: 'mono' = one picture for both eyes;
|
||||
// 'eac' = equi-angular cubemap; 'sbs' = 3D side by side (left eye's picture on the left).
|
||||
const IDS = Object.freeze(['flat', 'flatsbs', '360mono', '360eac', '180mono', '180sbs']);
|
||||
// The four formats. The ids are used everywhere: 'mono' = one picture for both eyes;
|
||||
// 'eac' = equi-angular cubemap.
|
||||
const IDS = Object.freeze(['flat', '360mono', '360eac', '180mono']);
|
||||
const DEFAULT = 'flat'; // when nothing is known about the video
|
||||
const isFormat = id => IDS.includes(id);
|
||||
|
||||
@@ -25,20 +22,14 @@
|
||||
// sites/youtube/page/provider/provider.js.)
|
||||
|
||||
// Horizontal span of the sphere, in radians (the cinema screen doesn't use a sphere).
|
||||
const longitudeSpan = fmt => (fmt === '180mono' || fmt === '180sbs' ? Math.PI : Math.PI * 2);
|
||||
|
||||
// Which part of the frame each eye sees, across: [scale, offset] for the left eye and the
|
||||
// right eye ([1, 0] = the whole frame; side by side, the left and right halves).
|
||||
const isSbs = fmt => fmt === '180sbs' || fmt === 'flatsbs';
|
||||
const eyeSpans = fmt => (isSbs(fmt) ? { left: [0.5, 0], right: [0.5, 0.5] } : { left: [1, 0], right: [1, 0] });
|
||||
const longitudeSpan = fmt => (fmt === '180mono' ? Math.PI : Math.PI * 2);
|
||||
|
||||
// How the sphere maps the frame: 0 = one wrap-around picture (equirectangular),
|
||||
// 1 = YouTube's equi-angular cubemap.
|
||||
const projectionMode = fmt => (fmt === '360eac' ? 1 : 0);
|
||||
|
||||
// Flat videos (2D, or 3D side by side) go on the cinema screen; everything else on the
|
||||
// video sphere.
|
||||
const isFlat = fmt => fmt === 'flat' || fmt === 'flatsbs';
|
||||
// Flat videos go on the cinema screen; everything else on the video sphere.
|
||||
const isFlat = fmt => fmt === 'flat';
|
||||
|
||||
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, eyeSpans, isSbs, projectionMode, isFlat };
|
||||
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, projectionMode, isFlat };
|
||||
})(globalThis.__streamxr = globalThis.__streamxr || {});
|
||||
@@ -15,17 +15,13 @@
|
||||
// Video formats: the full names (the popup's picker) and short ones (the VR panel's
|
||||
// Format button; \n = line break).
|
||||
'format.flat': 'Cinema screen (regular video)',
|
||||
'format.flatsbs': 'Cinema screen 3D side by side (both eyes in one frame)',
|
||||
'format.360mono': '360°',
|
||||
'format.360eac': '360° cubemap (YouTube "mesh")',
|
||||
'format.180mono': '180° (VR180)',
|
||||
'format.180sbs': '180° 3D side by side (both eyes in one frame)',
|
||||
'format.short.flat': 'Cinema\nscreen',
|
||||
'format.short.flatsbs': 'Cinema 3D\nside by side',
|
||||
'format.short.360mono': '360°',
|
||||
'format.short.360eac': '360°\ncubemap',
|
||||
'format.short.180mono': '180°',
|
||||
'format.short.180sbs': '180° 3D\nside by side',
|
||||
|
||||
// The popup on the video page, and the player's VR button.
|
||||
'popup.button': 'Watch in VR (SteamVR)',
|
||||
|
||||
@@ -7,17 +7,13 @@
|
||||
ns.i18n.add('es', {
|
||||
// Video formats.
|
||||
'format.flat': 'Pantalla de cine (vídeo normal)',
|
||||
'format.flatsbs': 'Pantalla de cine 3D lado a lado (los dos ojos en un fotograma)',
|
||||
'format.360mono': '360°',
|
||||
'format.360eac': '360° cubemap (YouTube "mesh")',
|
||||
'format.180mono': '180° (VR180)',
|
||||
'format.180sbs': '180° 3D lado a lado (los dos ojos en un fotograma)',
|
||||
'format.short.flat': 'Pantalla\nde cine',
|
||||
'format.short.flatsbs': 'Cine 3D\nlado a lado',
|
||||
'format.short.360mono': '360°',
|
||||
'format.short.360eac': '360°\ncubemap',
|
||||
'format.short.180mono': '180°',
|
||||
'format.short.180sbs': '180° 3D\nlado a lado',
|
||||
|
||||
// The popup and the player's VR button.
|
||||
'popup.button': 'Ver en VR (SteamVR)',
|
||||
|
||||
@@ -3,16 +3,13 @@
|
||||
// both worlds (the panel lists the formats; the renderer needs the projection). Which
|
||||
// format a video is, is decided by the site's provider (detectFormat).
|
||||
//
|
||||
// 3D: most 3D videos reach the desktop as one eye only (shown as mono); some arrive with both
|
||||
// eyes side by side in one frame, which '180sbs' (VR180) and 'flatsbs' (the cinema screen)
|
||||
// give each eye its half of. Nothing in the site's data marks those, so they're picked by hand
|
||||
// (docs/youtube.md, "Formats and VR180").
|
||||
// There are no 3D formats: on desktop YouTube sends one eye only, even for 3D videos.
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
// The six formats. The ids are used everywhere: 'mono' = one picture for both eyes;
|
||||
// 'eac' = equi-angular cubemap; 'sbs' = 3D side by side (left eye's picture on the left).
|
||||
const IDS = Object.freeze(['flat', 'flatsbs', '360mono', '360eac', '180mono', '180sbs']);
|
||||
// The four formats. The ids are used everywhere: 'mono' = one picture for both eyes;
|
||||
// 'eac' = equi-angular cubemap.
|
||||
const IDS = Object.freeze(['flat', '360mono', '360eac', '180mono']);
|
||||
const DEFAULT = 'flat'; // when nothing is known about the video
|
||||
const isFormat = id => IDS.includes(id);
|
||||
|
||||
@@ -26,20 +23,14 @@
|
||||
// sites/youtube/page/provider/provider.js.)
|
||||
|
||||
// Horizontal span of the sphere, in radians (the cinema screen doesn't use a sphere).
|
||||
const longitudeSpan = fmt => (fmt === '180mono' || fmt === '180sbs' ? Math.PI : Math.PI * 2);
|
||||
|
||||
// Which part of the frame each eye sees, across: [scale, offset] for the left eye and the
|
||||
// right eye ([1, 0] = the whole frame; side by side, the left and right halves).
|
||||
const isSbs = fmt => fmt === '180sbs' || fmt === 'flatsbs';
|
||||
const eyeSpans = fmt => (isSbs(fmt) ? { left: [0.5, 0], right: [0.5, 0.5] } : { left: [1, 0], right: [1, 0] });
|
||||
const longitudeSpan = fmt => (fmt === '180mono' ? Math.PI : Math.PI * 2);
|
||||
|
||||
// How the sphere maps the frame: 0 = one wrap-around picture (equirectangular),
|
||||
// 1 = YouTube's equi-angular cubemap.
|
||||
const projectionMode = fmt => (fmt === '360eac' ? 1 : 0);
|
||||
|
||||
// Flat videos (2D, or 3D side by side) go on the cinema screen; everything else on the
|
||||
// video sphere.
|
||||
const isFlat = fmt => fmt === 'flat' || fmt === 'flatsbs';
|
||||
// Flat videos go on the cinema screen; everything else on the video sphere.
|
||||
const isFlat = fmt => fmt === 'flat';
|
||||
|
||||
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, eyeSpans, isSbs, projectionMode, isFlat };
|
||||
ns.formats = { IDS, DEFAULT, isFormat, label, shortLabel, longitudeSpan, projectionMode, isFlat };
|
||||
})(globalThis.__streamxr = globalThis.__streamxr || {});
|
||||
@@ -16,17 +16,13 @@
|
||||
// Video formats: the full names (the popup's picker) and short ones (the VR panel's
|
||||
// Format button; \n = line break).
|
||||
'format.flat': 'Cinema screen (regular video)',
|
||||
'format.flatsbs': 'Cinema screen 3D side by side (both eyes in one frame)',
|
||||
'format.360mono': '360°',
|
||||
'format.360eac': '360° cubemap (YouTube "mesh")',
|
||||
'format.180mono': '180° (VR180)',
|
||||
'format.180sbs': '180° 3D side by side (both eyes in one frame)',
|
||||
'format.short.flat': 'Cinema\nscreen',
|
||||
'format.short.flatsbs': 'Cinema 3D\nside by side',
|
||||
'format.short.360mono': '360°',
|
||||
'format.short.360eac': '360°\ncubemap',
|
||||
'format.short.180mono': '180°',
|
||||
'format.short.180sbs': '180° 3D\nside by side',
|
||||
|
||||
// The popup on the video page, and the player's VR button.
|
||||
'popup.button': 'Watch in VR (SteamVR)',
|
||||
|
||||
@@ -8,17 +8,13 @@
|
||||
ns.i18n.add('es', {
|
||||
// Video formats.
|
||||
'format.flat': 'Pantalla de cine (vídeo normal)',
|
||||
'format.flatsbs': 'Pantalla de cine 3D lado a lado (los dos ojos en un fotograma)',
|
||||
'format.360mono': '360°',
|
||||
'format.360eac': '360° cubemap (YouTube "mesh")',
|
||||
'format.180mono': '180° (VR180)',
|
||||
'format.180sbs': '180° 3D lado a lado (los dos ojos en un fotograma)',
|
||||
'format.short.flat': 'Pantalla\nde cine',
|
||||
'format.short.flatsbs': 'Cine 3D\nlado a lado',
|
||||
'format.short.360mono': '360°',
|
||||
'format.short.360eac': '360°\ncubemap',
|
||||
'format.short.180mono': '180°',
|
||||
'format.short.180sbs': '180° 3D\nlado a lado',
|
||||
|
||||
// The popup and the player's VR button.
|
||||
'popup.button': 'Ver en VR (SteamVR)',
|
||||
|
||||
Reference in new issue
Block a user