21 Commits
Author SHA1 Message Date
phitandClaude Opus 5.5 416f64d9d1 chore: release 2.2.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:31:00 +02:00
phitandClaude Opus 5.5 0d0a2b33ba fix: antialias the headset's image
three.js passes the renderer's antialias setting on to the XR layer, so
this turns on multisampling in VR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:30:44 +02:00
phitandClaude Opus 5.5 ca98a5ed16 feat: only show the passthrough button where it works
Without passthrough the projection switcher takes its place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:30:44 +02:00
phitandClaude Opus 5.5 859de21258 docs: mention passthrough
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:23:34 +02:00
phitandClaude Opus 5.5 cfe82d49bc feat: passthrough in AR sessions
Where the browser supports immersive-ar (Chrome Canary on the Steam Frame),
sessions start as AR and a panel button shows the room around the video.
The player clears the frame itself, opaque or transparent, since three.js
always clears AR frames to transparent. Recenter and Exit VR on the panel
become icons to make room. YouTube's Enter VR button is now our own, so it
can start AR sessions too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:22:43 +02:00
phitandClaude Opus 5.5 e1e997cf95 refactor: drop "YouTube default" from the 360° EAC label
YouTube now detects the projection itself, so the hint no longer helps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:54:00 +02:00
phitandClaude Opus 5.5 eca0d9c101 docs: describe projection detection and the in-VR controls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:54:00 +02:00
phitandClaude Opus 5.5 b0f2a3353e feat: default other sites to the flat screen
Most videos on the web are flat, so that's the better first guess.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:51:06 +02:00
phitandClaude Opus 5.5 724125b138 feat: in-VR controls with movable, resizable screen
A Steam Frame styled panel with play/pause, seek, skip, volume, projection,
recenter and exit, used with a laser from each controller. The panel and
the flat screen can be moved with the bar below them or the grip, and the
screen resized from its corner; dragged windows stay upright, face the
viewer and are smoothed. Clicking the screen plays/pauses, double clicking
its sides skips. Also lowers the camera's near plane so close objects
aren't clipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:51:06 +02:00
phitandClaude Opus 5.5 9faae241a0 feat(youtube): detect the video's projection
Pre-selects the projection when the cardboard icon is clicked: 360, VR180
or flat from YouTube's player data, read by a new script in the page's own
world, and 3D from the frame itself, since YouTube doesn't report stereo
layouts. The two eyes' halves of a 3D frame are nearly identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:21:00 +02:00
phitandClaude Opus 5.5 409c9d91ed docs: link the Chrome Web Store listing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:40:00 +02:00
phitandClaude Opus 5.5 e5b6ac3df7 docs: drop the WebXR runtime flag step, OpenXR is the default
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:32:17 +02:00
phitandClaude Opus 5.5 b7c8e863d7 docs: add videos to try
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:32:17 +02:00
phitandClaude Opus 5.5 454944a7aa docs: add store install links, starting with Edge Add-ons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:32:17 +02:00
phitandClaude Opus 5.5 2c5683d0f3 docs: point Steam Frame users to Chrome Canary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:48:02 +02:00
phitandClaude Opus 5.5 150ee9a0aa chore: release 2.1.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:41:02 +02:00
phitandClaude Opus 5.5 28d182f5d4 fix: pad the 128px icon as the Chrome Web Store requires
Chrome's guidelines ask for 96x96 artwork with 16px of transparent
padding in the 128px icon, which shows in the install dialog and on
the extensions page. The 16-48px toolbar sizes stay unpadded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:41:02 +02:00
phitandClaude Opus 5.5 5051e164d1 docs: replace README screenshots
Use a 3D 360° (EAC) YouTube video in the headset, the YouTube controls
and the toolbar popup, resized to 1280px wide JPEGs in
docs/screenshots/, instead of the 1-4.5MB PNGs in the repo root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:41:02 +02:00
phitandClaude Opus 5.5 c587686bca fix: explain when the VR runtime refuses a session
Chrome only reports "The specified session configuration is not
supported" when SteamVR refuses a session, usually because another
browser holds the headset. Show what to do in the popup instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:30:10 +02:00
phitandClaude Opus 5.5 87dd16d6d1 fix: hide the whole VR-entering click from the site
Only the click event was stopped, but players and lightboxes often act
on pointerdown/mousedown/pointerup/mouseup and paused the video or
closed. Swallow every event of that gesture over the video at the
window capture phase, and start VR on pointerdown, the first event
Chrome accepts as user activation (touchend for touch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:30:10 +02:00
phitandClaude Opus 5.5 471f7ee2c1 feat: move other-site controls into the toolbar popup
The floating panel took focus from video lightboxes, which then
closed or lost their state. The toolbar button now opens a popup with
the projection (remembered between uses), Enter VR and Stop; the page
gets no controls. Since browsers only start VR from a gesture in the
page, Enter VR arms the video and the next click on it enters VR and
starts playback.

When a video's frames can't be read because its <video> doesn't
request CORS, reload it with crossorigin="anonymous" at the same
position, and restore it if the server doesn't allow that (fixes
Wikimedia Commons). Failed VR starts are reported in the popup.

The popup replaces the background service worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:20:33 +02:00
22 changed files with 1613 additions and 175 deletions

No files matched your search

+67 -21
View File
@@ -2,65 +2,111 @@
A Chromium extension that plays web videos in your PC VR headset through WebXR, using https://threejs.org/
- **YouTube:** 360°, VR180 and 3D videos, via a cardboard icon in the player
- **YouTube:** 360°, VR180, 3D and normal videos, via a cardboard icon in the player; the projection is detected automatically
- **Other sites:** any non-DRM video, via the extension's toolbar button
- **In VR:** a controls panel styled after the Steam Frame, and a flat screen you can move and resize with your controllers
![A YouTube VR180 video in the headset, with the browser floating in SteamVR](webxr-steamframe.png)
![A 3D 360° YouTube video in the headset, with the browser floating in SteamVR](docs/screenshots/headset-eac-3d.jpg)
### Install
1. Install a Chromium based browser - Chrome, Edge, Brave etc.
2. Download `video2webxr-<version>.zip` from the [latest release](https://github.com/phit/video2webxr/releases/latest) and unzip it to a folder
3. Open the extensions page in the browser and enable developer mode
4. Click "Load Unpacked" and choose the folder
**From a store**
- Chrome, Brave and other Chromium browsers: [Chrome Web Store](https://chromewebstore.google.com/detail/video2webxr/bacbclkmpiflnfeggheehlhhdmjjcfni)
- Microsoft Edge: [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/video2webxr/pegmdbgaelolcgnfadanmahjnlallpon)
**Manually**
1. Download `video2webxr-<version>.zip` from the [latest release](https://github.com/phit/video2webxr/releases/latest) and unzip it to a folder
2. Open the extensions page in the browser and enable developer mode
3. Click "Load Unpacked" and choose the folder
**On the Steam Frame:** works in Chrome Canary running on the Frame itself; see [phit/chromium-webxr-steam-frame (google branch)](https://github.com/phit/chromium-webxr-steam-frame/tree/google) for how to set it up
### Usage
1. Turn VR on first (e.g. start SteamVR)
2. Start a new browser so that it detects the hardware
On the Steam Frame itself, skip these two steps: just open Chrome Canary.
**On YouTube**
1. Open a 360° or VR180 video, e.g. on https://www.youtube.com/vr/
1. Open a video: 360° or VR180 (e.g. from https://www.youtube.com/vr/), or a normal one for the flat screen
2. Click on the white cardboard icon in the bottom right hand corner of the video
3. Pick the video's projection in the dropdown next to the "Enter VR" button
3. The dropdown next to the "Enter VR" button shows the detected projection. 3D is recognised from the picture itself, so it switches to the 3D option once the video shows something other than a dark frame. Change it if it looks wrong
4. Click on the "Enter VR" button below the video
![YouTube with the Enter VR button and the projection dropdown set to 360° EAC 3D](docs/screenshots/youtube-eac-3d.jpg)
**On other sites**
1. Start playing the video
2. Click the video2webxr button in the browser toolbar (pin it from the extensions menu)
3. A panel with "Enter VR", the projection dropdown and "Close" appears at the top of the page; it defaults to 180° 3D side by side
3. Pick the projection in the popup (defaults to Flat screen; your choice is remembered) and click "Enter VR"
4. Click the video in the page to enter VR. Browsers only start VR from a click in the page itself, so this step can't be skipped
5. Use the controls in VR, or open the popup again to change the projection or click "Stop"
![The panel with Enter VR, the projection dropdown and Close](videomenu.png)
4. Click "Enter VR"; click the toolbar button again or "Close" to stop
![The video2webxr popup open from the toolbar on a Wikimedia Commons 360° video](docs/screenshots/other-sites-popup.jpg)
The page itself gets no extra controls, so video lightboxes and overlays keep working.
**In VR**
A controls panel floats below the video: play/pause, the seek bar, skip 10 seconds back or forward, mute and volume, the projection (‹ › to switch), passthrough, Recenter and Exit VR.
| To | Do this |
|---|---|
| Use a control | Point the controller's laser at it and pull the trigger; hold the trigger to drag the seek or volume slider |
| Hide or show the panel | Press A/X or B/Y. Pulling the trigger away from the screen also shows it, and it comes back in front of you if it was out of view |
| Move the panel or the flat screen | Drag the bar below it with the trigger, or hold the grip anywhere on it. It stays upright and turns to face you |
| Move it closer or further away | While holding it, push the thumbstick forward or back |
| Resize the flat screen | Drag its bottom right corner, or push the thumbstick left or right while holding it |
| Play or pause | Click the flat screen |
| Skip 10 seconds back or forward | Double click the left or right third of the flat screen, like on YouTube's mobile app; each further click skips another 10 seconds |
| See the room around the screen | Press the passthrough button (the headset icon). It works where the browser supports AR with passthrough, e.g. Chrome Canary on the Steam Frame ([install guide](https://github.com/phit/chromium-webxr-steam-frame/tree/google#install-on-the-frame)), and only shows there |
| Put everything back in front of you | Recenter on the panel. On 360° and 180° videos it also turns the front of the video to where you look |
### Videos to try
| Video | Where | Projection |
|---|---|---|
| [Caminandes VR demo](https://www.youtube.com/watch?v=uvy--ElpfF8) | YouTube | 360° EAC 3D |
| [Tomorrowland 360°](https://www.youtube.com/watch?v=y0k8Akk1yN0) | YouTube | 360° EAC |
| [Elton John VR180](https://www.youtube.com/watch?v=9r6NI9m_IxM) | YouTube | 180° |
| [Bled Lake 360° timelapse](https://commons.wikimedia.org/wiki/File:Video_360%C2%B0._Timelapse._Bled_Lake_in_Slovenia..webm) | Wikimedia Commons, via the toolbar button | 360° equirectangular |
### Projections
| Projection | Use it for |
|---|---|
| 360° EAC (YouTube default) | 360° videos on YouTube - this is how YouTube serves them on desktop |
| 360° EAC | 360° videos on YouTube - this is how YouTube serves them on desktop |
| 360° EAC 3D | 3D 360° videos served as stereo EAC |
| 360° cubemap | videos in a plain 3x2 cubemap layout |
| 360° equirectangular | 360° videos served as a single equirectangular image (2:1) |
| 360° 3D top/bottom, side by side | 3D equirectangular 360° videos |
| 180° | VR180 videos on YouTube, and other mono 180° videos |
| 180° 3D side by side (other sites' default) | 3D 180° videos with both eyes side by side, the most common VR video format |
| Flat screen | normal videos, shown on a virtual screen in front of you |
| 180° 3D side by side | 3D 180° videos with both eyes side by side, the most common VR video format |
| Flat screen (other sites' default) | normal videos, shown on a virtual screen in front of you |
You can switch the projection while in VR. If the image looks jumbled into squares, try a 360° EAC option; if it looks stretched or doubled, try the others.
You can switch the projection while in VR, from the panel. If the image looks jumbled into squares, try a 360° EAC option; if it looks stretched or doubled, try the others.
### Limitations
- DRM-protected videos (Netflix, Prime Video, Disney+ etc.) can't be shown: the browser won't hand protected frames to WebGL
- Videos served from another domain without CORS headers can't be read by WebGL either; the extension tells you when that's the case
- Videos served from another domain without CORS headers can't be read by WebGL either; the extension retries with CORS turned on and tells you if that doesn't work
- On other sites the toolbar button can only reach videos in the page itself and in same-site frames
- The Steam Frame's built-in browser doesn't work with it yet; see [saphid/chromium-webxr-steam-frame](https://github.com/saphid/chromium-webxr-steam-frame) for a community workaround
### Troubleshooting
If "Enter VR" says "VR NOT SUPPORTED", the browser hasn't found your headset:
- Make sure SteamVR is running and the headset is connected
- Set "WebXR runtime" to OpenXR at `chrome://flags/#webxr-runtime` and restart the browser
If "Enter VR" says "VR NOT SUPPORTED", the browser hasn't found your headset: make sure SteamVR is running and the headset is connected, then restart the browser.
If clicking the video doesn't enter VR, close other browser windows that use the headset; only one browser can be in VR at a time. The popup shows why the last attempt failed.
If you don't see the white cardboard icon in the bottom right hand corner of a YouTube video then the extension is not installed or disabled
### Development
Sources are in `src/`: `youtube.js` (YouTube integration), `generic.js` (toolbar button on other sites), `vr-player.js` (the shared WebXR player) and `background.js` (the service worker).
Sources are in `src/`:
- `youtube.js`: YouTube integration (the cardboard icon, projection detection)
- `youtube-page.js`: runs in YouTube's own page context to read the player's data
- `stereo.js`: tells 3D frames from mono ones
- `generic.js`, `popup.*`: the toolbar button and its popup for other sites
- `vr-player.js`: the shared WebXR player and projections
- `vr-controls.js`: the in-VR panel, lasers, moving and resizing
- `projections.js`: the projection names, shared by the player and the popup
```
npm install
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

+8 -5
View File
@@ -1,17 +1,19 @@
{
"name": "video2webxr",
"version": "2.0.0",
"version": "2.2.0",
"description": "Watch YouTube 360°/VR180 videos and videos on other sites in your PC VR headset via WebXR",
"manifest_version": 3,
"content_scripts": [
{
"matches": ["*://*.youtube.com/*"],
"js": ["script.js"]
},
{
"matches": ["*://*.youtube.com/*"],
"js": ["youtube-page.js"],
"world": "MAIN"
}
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "Watch this video in VR",
"default_icon": {
@@ -19,7 +21,8 @@
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"default_popup": "popup.html"
},
"permissions": ["activeTab", "scripting"],
"icons": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "video2webxr",
"version": "2.0.0",
"version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "video2webxr",
"version": "2.0.0",
"version": "2.2.0",
"license": "ISC",
"dependencies": {
"three": "^0.186.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "video2webxr",
"version": "2.0.0",
"version": "2.2.0",
"description": "Watch YouTube 360°/VR180 videos and videos on other sites in a PC VR headset via WebXR",
"main": "src/youtube.js",
"scripts": {
+8 -2
View File
@@ -12,7 +12,8 @@ const outdir = 'dist';
const STATIC_FILES = [
'manifest.json',
'LICENSE',
'src/background.js',
'src/popup.html',
'src/popup.css',
'icons/icon16.png',
'icons/icon32.png',
'icons/icon48.png',
@@ -28,7 +29,12 @@ for (const file of STATIC_FILES) {
}
const context = await esbuild.context({
entryPoints: { script: 'src/youtube.js', generic: 'src/generic.js' },
entryPoints: {
script: 'src/youtube.js',
'youtube-page': 'src/youtube-page.js',
generic: 'src/generic.js',
popup: 'src/popup.js',
},
outdir,
bundle: true,
format: 'iife',
-44
View File
@@ -1,44 +0,0 @@
// Toolbar button: start (or stop) VR on the largest video in the current tab.
// activeTab grants access to the tab's frames only after the user clicks the button.
chrome.action.onClicked.addListener(async (tab) => {
let frames;
try {
frames = await chrome.scripting.executeScript({
target: { tabId: tab.id, allFrames: true },
func: measureVideos,
});
} catch {
// some frames are off limits; fall back to the top frame
frames = await chrome.scripting
.executeScript({ target: { tabId: tab.id }, func: measureVideos })
.catch(() => []);
}
const best = frames.filter((frame) => frame.result).sort((a, b) => b.result - a.result)[0];
await chrome.scripting
.executeScript({
target: { tabId: tab.id, frameIds: [best ? best.frameId : 0] },
files: ['generic.js'],
})
.catch((e) => console.warn("can't start VR in this tab:", e.message));
});
// Runs in each frame: the area of its largest video, or a huge number if VR is already
// running there so that clicking again turns it off
function measureVideos() {
if (window.__pcvrGeneric) return Number.MAX_SAFE_INTEGER;
const videos = [];
const walk = (root) => {
videos.push(...root.querySelectorAll('video'));
for (const element of root.querySelectorAll('*')) {
if (element.shadowRoot) walk(element.shadowRoot);
}
};
walk(document);
return Math.max(
0,
...videos.map((video) => {
const rect = video.getBoundingClientRect();
return rect.width * rect.height;
}),
);
}
+231 -74
View File
@@ -1,10 +1,13 @@
import { styleControl, VRPlayer } from './vr-player.js';
import { DEFAULT_PROJECTION } from './projections.js';
import { VRPlayer } from './vr-player.js';
// Injected by background.js into the frame with the largest video when the toolbar button is
// clicked. Running it again in the same frame turns VR off.
// Injected by the popup into the frame with the largest video. The popup sends commands; the
// page itself gets no controls, so sites with lightboxes or focus traps aren't disturbed.
// Chrome only starts a VR session from a user gesture in the page, so "arm" waits for the next
// click on the video and enters VR from there.
// Most VR videos on other sites are 180° side by side
const DEFAULT_PROJECTION = '180_LR';
const NOT_READABLE_CORS =
"This site serves the video from another domain without allowing it to be read (CORS), so it can't be shown in VR.";
function findVideos(root = document) {
const videos = [...root.querySelectorAll('video')];
@@ -22,87 +25,241 @@ function largestVideo() {
return findVideos().sort((a, b) => area(b) - area(a))[0];
}
function waitFor(video, event, timeout) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`no ${event}`)), timeout);
video.addEventListener(
event,
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
video.addEventListener('error', () => reject(new Error('video error')), { once: true });
});
}
// WebGL can only use video frames the page is allowed to read
function whyUnusable(video) {
function isReadable(video) {
try {
const context = document.createElement('canvas').getContext('2d');
context.drawImage(video, 0, 0, 1, 1);
context.getImageData(0, 0, 1, 1);
return true;
} catch (e) {
if (e.name === 'SecurityError') return false;
throw e;
}
}
// Reloads the video in CORS mode, keeping its position, for sites whose video server allows
// cross-origin reads but whose <video> element doesn't ask for them (e.g. Wikimedia Commons)
async function reloadWithCors(video, crossOrigin) {
const time = video.currentTime;
const playing = !video.paused;
if (crossOrigin) video.crossOrigin = crossOrigin;
else video.removeAttribute('crossorigin');
video.load();
await waitFor(video, 'loadeddata', 10000);
video.currentTime = time;
if (playing) video.play().catch(() => {});
}
// Returns why the video can't be used in VR, or null if it can
async function whyUnusable(video) {
if (video.mediaKeys) {
return "This video is DRM-protected, so the browser won't let it be shown in VR.";
}
if (video.readyState >= video.HAVE_CURRENT_DATA) {
try {
const context = document.createElement('canvas').getContext('2d');
context.drawImage(video, 0, 0, 1, 1);
context.getImageData(0, 0, 1, 1);
} catch (e) {
if (e.name === 'SecurityError') {
return "This site serves the video from another domain without allowing it to be read (CORS), so it can't be shown in VR.";
}
if (video.readyState < video.HAVE_CURRENT_DATA) {
await waitFor(video, 'loadeddata', 5000).catch(() => {});
if (video.readyState < video.HAVE_CURRENT_DATA) return null; // can't tell yet
}
if (isReadable(video)) return null;
if (video.crossOrigin !== null || video.currentSrc.startsWith('blob:')) return NOT_READABLE_CORS;
try {
await reloadWithCors(video, 'anonymous');
if (isReadable(video)) return null;
} catch {
// the server doesn't allow CORS after all; put the site's video back as it was
await reloadWithCors(video, null).catch(() => {});
}
return NOT_READABLE_CORS;
}
function createController() {
let state = 'idle'; // idle | armed | starting | active
let lastError = null;
let projection = DEFAULT_PROJECTION;
let video = null;
let player = null;
let hint = null;
let removedCheck = null;
function showHint(text) {
hideHint();
const rect = video.getBoundingClientRect();
hint = document.createElement('div');
hint.textContent = text;
Object.assign(hint.style, {
position: 'fixed',
left: `${rect.left + rect.width / 2}px`,
top: `${rect.top + rect.height / 2}px`,
transform: 'translate(-50%, -50%)',
zIndex: '2147483647',
pointerEvents: 'none', // clicks go through to the video
padding: '12px 18px',
borderRadius: '6px',
background: 'rgba(0,0,0,0.75)',
color: '#fff',
font: 'normal 15px sans-serif',
});
// next to the video, so it shows inside lightboxes and fullscreen elements too
(video.parentElement || document.body).appendChild(hint);
}
function hideHint() {
hint?.remove();
hint = null;
}
// All events of the click that enters VR. The site must see none of them, or it may pause the
// video, close its lightbox or start a drag.
const GESTURE_EVENTS = ['pointerdown', 'mousedown', 'touchstart', 'pointerup', 'mouseup', 'touchend', 'click'];
let gestureStarted = false;
let vrRequested = false;
let gestureTimeout = null;
function isOnVideo(event) {
const { clientX, clientY } = event.changedTouches?.[0] ?? event;
const rect = video.getBoundingClientRect();
return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
}
// Chrome only starts VR from events that count as user activation
function activatesUser(event) {
if (event.type === 'pointerdown') return event.pointerType !== 'touch';
if (event.type === 'pointerup') return event.pointerType === 'touch';
return event.type === 'mousedown' || event.type === 'touchend' || event.type === 'click';
}
function listenToGesture(listen) {
for (const type of GESTURE_EVENTS) {
if (listen) window.addEventListener(type, onGestureEvent, { capture: true, passive: false });
else window.removeEventListener(type, onGestureEvent, true);
}
}
return null;
}
function start(video) {
const player = new VRPlayer(video, DEFAULT_PROJECTION);
// capture phase on window, so this runs before the site's own handlers
function onGestureEvent(event) {
if (!gestureStarted) {
if (!isOnVideo(event)) return;
gestureStarted = true;
// swallow the rest of this click wherever it ends, then stop listening
gestureTimeout = setTimeout(endGesture, 1000);
}
event.preventDefault();
event.stopImmediatePropagation();
if (!vrRequested && activatesUser(event)) {
vrRequested = true;
startVR();
}
if (event.type === 'click') endGesture();
}
const closeButton = document.createElement('button');
closeButton.textContent = 'CLOSE';
styleControl(closeButton);
function endGesture() {
clearTimeout(gestureTimeout);
listenToGesture(false);
gestureStarted = false;
vrRequested = false;
}
const panel = document.createElement('div');
Object.assign(panel.style, {
position: 'fixed',
top: '16px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: '2147483647',
padding: '8px',
borderRadius: '6px',
background: 'rgba(0,0,0,0.7)',
});
panel.appendChild(player.createControls(closeButton));
(document.fullscreenElement || document.body).appendChild(panel);
function startVR() {
hideHint();
// the site doesn't see this click, so start playback ourselves
if (video.paused) video.play().catch(() => {});
player = new VRPlayer(video, projection);
state = 'starting';
console.log('video2webxr: entering VR');
player
.enterVR()
.then((session) => {
console.log('video2webxr: in VR');
state = 'active';
session.addEventListener('end', stop);
})
.catch((e) => {
console.warn('video2webxr: entering VR failed:', e);
stop();
// what Chrome reports when SteamVR refuses the session, usually because another
// browser already holds the headset
lastError =
e.name === 'NotSupportedError'
? "The VR runtime couldn't start a session. Another browser or app may be using the headset: close it, or restart SteamVR, and try again."
: `Entering VR failed: ${e.message}`;
});
}
const close = () => {
console.log('disabling VR');
function stop() {
endGesture();
hideHint();
clearInterval(removedCheck);
video.removeEventListener('emptied', close);
player.dispose();
panel.remove();
delete window.__pcvrGeneric;
video?.removeEventListener('emptied', stop);
player?.dispose();
player = null;
state = 'idle';
}
async function arm(newProjection) {
projection = newProjection;
lastError = null;
if (state === 'active') {
player.setProjection(projection);
return { state };
}
stop();
video = largestVideo();
if (!video) return { error: 'No video found on this page.' };
const problem = await whyUnusable(video);
if (problem) return { error: problem };
// stop when the site swaps or removes the video
video.addEventListener('emptied', stop);
removedCheck = setInterval(() => {
if (!video.isConnected) stop();
}, 1000);
listenToGesture(true);
showHint('Click the video to enter VR');
state = 'armed';
return { state };
}
return {
get state() {
return state;
},
async handle(message) {
switch (message.type) {
case 'state':
return { state, projection, error: lastError };
case 'arm':
return arm(message.projection);
case 'setProjection':
projection = message.projection;
player?.setProjection(projection);
return { state };
case 'stop':
stop();
return { state };
}
},
};
closeButton.onclick = close;
// stop when the site swaps or removes the video
video.addEventListener('emptied', close);
const removedCheck = setInterval(() => {
if (!video.isConnected) close();
}, 1000);
window.__pcvrGeneric = { close };
}
function main() {
if (window.__pcvrGeneric) {
window.__pcvrGeneric.close();
return;
}
// YouTube has its own integration behind the cardboard icon
const cardboard = document.querySelector('#cardboardimg');
if (cardboard) {
cardboard.click();
return;
}
const video = largestVideo();
if (!video) {
alert('No video found on this page.');
return;
}
const problem = whyUnusable(video);
if (problem) {
alert(problem);
return;
}
console.log(`enabling VR on ${video.currentSrc}`);
start(video);
// the popup may inject this script more than once
if (!window.__video2webxr) {
window.__video2webxr = createController();
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
window.__video2webxr.handle(message).then(sendResponse, (e) => sendResponse({ error: e.message }));
return true; // responds asynchronously
});
}
main();
+60
View File
@@ -0,0 +1,60 @@
body {
width: 280px;
margin: 0;
padding: 14px 16px 16px;
background: #202124;
color: #e8eaed;
font:
13px system-ui,
sans-serif;
}
h1 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 10px;
font-size: 15px;
}
h1 img {
width: 20px;
height: 20px;
}
#status {
margin: 0 0 12px;
line-height: 1.4;
}
#status.error {
color: #f28b82;
}
label {
display: block;
margin-bottom: 4px;
color: #9aa0a6;
}
select,
button {
width: 100%;
padding: 7px 8px;
border: 1px solid #5f6368;
border-radius: 4px;
background: #303134;
color: inherit;
font: inherit;
}
.buttons {
display: flex;
gap: 8px;
margin-top: 12px;
}
button {
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.5;
}
#enter:not(:disabled) {
border-color: #1a73e8;
background: #1a73e8;
color: #fff;
}
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<h1><img src="icons/icon32.png" alt="" /> video2webxr</h1>
<p id="status">Looking for a video…</p>
<label for="projection">Projection</label>
<select id="projection"></select>
<div class="buttons">
<button type="button" id="enter" disabled>Enter VR</button>
<button type="button" id="stop" hidden>Stop</button>
</div>
<script src="popup.js"></script>
</body>
</html>
+126
View File
@@ -0,0 +1,126 @@
import { DEFAULT_PROJECTION, PROJECTION_LABELS } from './projections.js';
// The extension's toolbar popup. Finds the largest video in the tab, injects generic.js into
// its frame and sends it commands. The page gets no controls of its own.
const status = document.getElementById('status');
const projectionSelect = document.getElementById('projection');
const enterButton = document.getElementById('enter');
const stopButton = document.getElementById('stop');
let tab;
let target = null; // { frameId, state } of the frame with the video
function setStatus(text, isError = false) {
status.textContent = text;
status.classList.toggle('error', isError);
}
function showState(state) {
target.state = state;
enterButton.hidden = state !== 'idle' && state !== 'none';
stopButton.hidden = !enterButton.hidden;
if (state === 'armed') setStatus('Click the video in the page to enter VR.');
else if (state === 'starting')
setStatus('Starting VR… check that SteamVR is running and no other browser is in VR.');
else if (state === 'active') setStatus('In VR. Changing the projection applies right away.');
}
// Runs in each frame of the tab: the area of its largest video and the controller state
function probeFrame() {
const videos = [];
const walk = (root) => {
videos.push(...root.querySelectorAll('video'));
for (const element of root.querySelectorAll('*')) {
if (element.shadowRoot) walk(element.shadowRoot);
}
};
walk(document);
const area = Math.max(
0,
...videos.map((video) => video.getBoundingClientRect().width * video.getBoundingClientRect().height),
);
return { area, state: window.__video2webxr ? window.__video2webxr.state : 'none' };
}
async function findTarget() {
let frames;
try {
frames = await chrome.scripting.executeScript({ target: { tabId: tab.id, allFrames: true }, func: probeFrame });
} catch {
// some frames are off limits; fall back to the top frame
frames = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: probeFrame });
}
const results = frames.filter((frame) => frame.result);
// a frame that's already armed or in VR wins, so the popup can stop it
const running = results.find((frame) => ['armed', 'starting', 'active'].includes(frame.result.state));
const largest = results.filter((frame) => frame.result.area > 0).sort((a, b) => b.result.area - a.result.area)[0];
const frame = running || largest;
return frame && { frameId: frame.frameId, state: frame.result.state };
}
function send(message) {
return chrome.tabs.sendMessage(tab.id, message, { frameId: target.frameId });
}
async function enter() {
enterButton.disabled = true;
try {
if (target.state === 'none') {
await chrome.scripting.executeScript({
target: { tabId: tab.id, frameIds: [target.frameId] },
files: ['generic.js'],
});
}
const reply = await send({ type: 'arm', projection: projectionSelect.value });
if (reply.error) setStatus(reply.error, true);
else showState(reply.state);
} catch (e) {
setStatus(`Couldn't start: ${e.message}`, true);
}
enterButton.disabled = false;
}
async function stop() {
const reply = await send({ type: 'stop' }).catch(() => ({ state: 'idle' }));
showState(reply.state);
setStatus('Stopped.');
}
async function init() {
for (const [value, label] of PROJECTION_LABELS) projectionSelect.add(new Option(label, value));
projectionSelect.value = localStorage.getItem('projection') || DEFAULT_PROJECTION;
projectionSelect.onchange = () => {
localStorage.setItem('projection', projectionSelect.value);
if (target && target.state !== 'none')
send({ type: 'setProjection', projection: projectionSelect.value }).catch(() => {});
};
enterButton.onclick = enter;
stopButton.onclick = stop;
[tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (/^https?:\/\/([^/]+\.)?youtube\.com\//.test(tab.url || '')) {
setStatus('On YouTube, use the cardboard icon in the video player.');
return;
}
try {
target = await findTarget();
} catch {
setStatus("This page can't be accessed by extensions.", true);
return;
}
if (!target) {
setStatus('No video found on this page.', true);
return;
}
setStatus('Video found. Enter VR, then click the video in the page.');
enterButton.disabled = false;
showState(target.state);
// tell the user why the last attempt didn't work
if (target.state === 'idle') {
const reply = await send({ type: 'state' }).catch(() => ({}));
if (reply.error) setStatus(reply.error, true);
}
}
init();
+15
View File
@@ -0,0 +1,15 @@
// Projection ids and labels, in dropdown order. Kept free of three.js so the popup can use it.
export const PROJECTION_LABELS = [
['EAC', '360° EAC'],
['EAC_LR', '360° EAC 3D'],
['CUBE', '360° cubemap'],
['360', '360° equirectangular'],
['360_TB', '360° 3D top/bottom'],
['360_LR', '360° 3D side by side'],
['180', '180°'],
['180_LR', '180° 3D side by side'],
['FLAT', 'Flat screen'],
];
// Most videos on the web are plain flat videos
export const DEFAULT_PROJECTION = 'FLAT';
+64
View File
@@ -0,0 +1,64 @@
// Tells 3D videos from mono ones by their pixels: a 3D frame holds the two eyes' views next to
// each other (or above each other), and those are nearly the same picture.
const SIZE = 64; // the frame is scaled down to SIZE x SIZE, which is plenty to compare halves
// Correlation of two equally long brightness arrays, from -1 to 1; null if either is flat
function correlation(a, b) {
const n = a.length;
let meanA = 0;
let meanB = 0;
for (let i = 0; i < n; i++) {
meanA += a[i];
meanB += b[i];
}
meanA /= n;
meanB /= n;
let cov = 0;
let varA = 0;
let varB = 0;
for (let i = 0; i < n; i++) {
const da = a[i] - meanA;
const db = b[i] - meanB;
cov += da * db;
varA += da * da;
varB += db * db;
}
// a black or single-coloured frame (fades, intros) says nothing
if (varA / n < 25 || varB / n < 25) return null;
return cov / Math.sqrt(varA * varB);
}
// How alike the two halves of the current frame are, split 'LR' (left/right) or 'TB'
// (top/bottom); null if the frame can't be read or is too plain to tell
export function halvesCorrelation(video, split) {
if (video.readyState < video.HAVE_CURRENT_DATA) return null;
const canvas = document.createElement('canvas');
canvas.width = SIZE;
canvas.height = SIZE;
const context = canvas.getContext('2d', { willReadFrequently: true });
let pixels;
try {
context.drawImage(video, 0, 0, SIZE, SIZE);
pixels = context.getImageData(0, 0, SIZE, SIZE).data;
} catch {
return null; // not readable (CORS)
}
const half = SIZE / 2;
const a = new Float32Array(half * SIZE);
const b = new Float32Array(half * SIZE);
let i = 0;
for (let y = 0; y < (split === 'LR' ? SIZE : half); y++) {
for (let x = 0; x < (split === 'LR' ? half : SIZE); x++) {
const other = split === 'LR' ? (y * SIZE + x + half) * 4 : ((y + half) * SIZE + x) * 4;
const own = (y * SIZE + x) * 4;
a[i] = pixels[own] * 0.3 + pixels[own + 1] * 0.59 + pixels[own + 2] * 0.11;
b[i] = pixels[other] * 0.3 + pixels[other + 1] * 0.59 + pixels[other + 2] * 0.11;
i++;
}
}
return correlation(a, b);
}
// Above this the halves are taken to be the two eyes of one picture
export const STEREO_THRESHOLD = 0.85;
+796
View File
@@ -0,0 +1,796 @@
import * as THREE from 'three';
import { PROJECTION_LABELS } from './projections.js';
// In-VR controls: a laser from each controller, a panel with the video's controls, and
// moving things around like in the Steam Frame's UI. Point the laser and pull the trigger to
// use a control. The panel and the flat screen each have a bar below them: drag it with the
// trigger to move them (or hold the grip anywhere on them); while held, the thumbstick moves
// them away/closer and resizes them. The screen's bottom right corner resizes it. A/X or B/Y
// hide and show the panel, and the trigger shows it again too. Clicking the flat screen
// plays/pauses; double clicking its left or right third skips back or forward.
// The panel is drawn into a canvas and shown on a plane PANEL_WIDTH metres wide
const CANVAS_W = 1280;
const CANVAS_H = 256;
const PANEL_WIDTH = 1.0;
const PANEL_HEIGHT = (PANEL_WIDTH * CANVAS_H) / CANVAS_W;
const SKIP_SECONDS = 10;
const DOUBLE_CLICK_MS = 300;
const LASER_LENGTH = 5;
const HANDLE_OPACITY = 0.5;
const MIN_SCALE = 0.2;
const MAX_SCALE = 10;
// How quickly dragged windows catch up with the controller (per second); lower is smoother
// but lags more
const SMOOTHING = 12;
// xr-standard gamepad mapping
const BUTTON_A_X = 4;
const BUTTON_B_Y = 5;
const AXIS_STICK_X = 2;
const AXIS_STICK_Y = 3;
// Hit areas on the canvas, in canvas pixels
const ROW_TOP = 24;
const ROW_BOTTOM = 140;
const ROW_HEIGHT = 92;
const AREAS_WITH_PASSTHROUGH = {
seek: { x: 150, y: ROW_TOP + 28, w: 980, h: 36 },
back: { x: 16, y: ROW_BOTTOM, w: 88, h: ROW_HEIGHT },
play: { x: 112, y: ROW_BOTTOM, w: 88, h: ROW_HEIGHT },
forward: { x: 208, y: ROW_BOTTOM, w: 88, h: ROW_HEIGHT },
mute: { x: 328, y: ROW_BOTTOM, w: 88, h: ROW_HEIGHT },
volume: { x: 428, y: ROW_BOTTOM + 28, w: 172, h: 36 },
previousProjection: { x: 616, y: ROW_BOTTOM, w: 56, h: ROW_HEIGHT },
nextProjection: { x: 912, y: ROW_BOTTOM, w: 56, h: ROW_HEIGHT },
passthrough: { x: 984, y: ROW_BOTTOM, w: 88, h: ROW_HEIGHT },
recenter: { x: 1080, y: ROW_BOTTOM, w: 88, h: ROW_HEIGHT },
exit: { x: 1176, y: ROW_BOTTOM, w: 88, h: ROW_HEIGHT },
};
// Without passthrough (no AR session) the projection switcher takes the button's place
const { passthrough: _, ...withoutPassthrough } = AREAS_WITH_PASSTHROUGH;
const AREAS_WITHOUT_PASSTHROUGH = {
...withoutPassthrough,
nextProjection: { x: 1004, y: ROW_BOTTOM, w: 56, h: ROW_HEIGHT },
};
const SLIDERS = new Set(['seek', 'volume']);
// Steam Deck / Steam Frame colours
const THEME = {
panel: 'rgba(35, 38, 46, 0.96)',
focus: '#3d4450',
button: '#323843',
accent: '#1a9fff',
track: '#4b5260',
buffered: '#6b7280',
separator: '#3a3f4a',
text: '#dcdedf',
label: '#b8bcbf',
font: '"Motiva Sans", "Segoe UI", system-ui, sans-serif',
};
// A transparent plane with a white shape drawn by draw(context, width, height); the plane is
// bigger than the shape so it's easy to hit
function handleMesh(width, height, pixelsPerMetre, draw) {
const canvas = document.createElement('canvas');
canvas.width = Math.round(width * pixelsPerMetre);
canvas.height = Math.round(height * pixelsPerMetre);
const context = canvas.getContext('2d');
context.fillStyle = '#fff';
context.strokeStyle = '#fff';
draw(context, canvas.width, canvas.height);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return new THREE.Mesh(
new THREE.PlaneGeometry(width, height),
new THREE.MeshBasicMaterial({ map: texture, transparent: true, opacity: HANDLE_OPACITY, depthWrite: false }),
);
}
// The pill-shaped bar under a window, dragged to move it
function moveBar(width) {
return handleMesh(width, width / 6, 1024, (c, w, h) => {
const barHeight = h / 4;
c.beginPath();
c.roundRect(0, (h - barHeight) / 2, w, barHeight, barHeight / 2);
c.fill();
});
}
// The rounded corner bracket at a window's bottom right, dragged to resize it. The plane is
// centred on the window's corner; the bracket sits just outside it.
function resizeCorner(size) {
return handleMesh(size, size, 1024, (c, w, h) => {
const line = w / 14;
const radius = w / 5;
const gap = line * 1.5; // between the window's edge and the bracket
const right = w / 2 + gap;
const bottom = h / 2 + gap;
const length = w / 2 - line;
c.lineWidth = line;
c.lineCap = 'round';
c.beginPath();
c.moveTo(right, bottom - length);
c.lineTo(right, bottom - radius);
c.arcTo(right, bottom, right - radius, bottom, radius);
c.lineTo(right - length, bottom);
c.stroke();
});
}
function formatTime(seconds) {
if (!Number.isFinite(seconds)) return '--:--';
const s = Math.floor(seconds % 60)
.toString()
.padStart(2, '0');
const m = Math.floor(seconds / 60) % 60;
const h = Math.floor(seconds / 3600);
return h ? `${h}:${m.toString().padStart(2, '0')}:${s}` : `${m}:${s}`;
}
export class VRControls {
// player: the VRPlayer whose video, scene and screen these controls drive
constructor(player) {
this.player = player;
this.video = player.video;
this.renderer = player.renderer;
this.raycaster = new THREE.Raycaster();
this.canvas = document.createElement('canvas');
this.canvas.width = CANVAS_W;
this.canvas.height = CANVAS_H;
this.context = this.canvas.getContext('2d');
this.texture = new THREE.CanvasTexture(this.canvas);
this.texture.colorSpace = THREE.SRGBColorSpace;
this.panel = new THREE.Mesh(
new THREE.PlaneGeometry(PANEL_WIDTH, PANEL_HEIGHT),
new THREE.MeshBasicMaterial({ map: this.texture, transparent: true }),
);
this.panel.visible = false;
this.resetPanelPosition();
player.scene.add(this.panel);
// handles: each knows what it moves or resizes
this.panelBar = moveBar(0.3);
this.panelBar.userData = { handle: 'move', target: this.panel };
this.panel.add(this.panelBar);
this.screenBar = moveBar(0.5);
this.screenBar.userData = { handle: 'move', target: player.screen };
this.screenCorner = resizeCorner(0.3);
this.screenCorner.userData = { handle: 'resize', target: player.screen };
player.screen.add(this.screenBar, this.screenCorner);
this.handles = [this.panelBar, this.screenBar, this.screenCorner];
this.plane = new THREE.Plane();
this.quaternion = new THREE.Quaternion();
this.facing = new THREE.Object3D(); // works out which way a dragged window faces
this.controllers = [0, 1].map((index) => this.createController(index));
this.onSessionStart = () => {
this.panel.visible = true;
};
this.onSessionEnd = () => {
this.panel.visible = false;
for (const controller of this.controllers) this.release(controller);
};
this.renderer.xr.addEventListener('sessionstart', this.onSessionStart);
this.renderer.xr.addEventListener('sessionend', this.onSessionEnd);
}
createController(index) {
const controller = this.renderer.xr.getController(index);
const laser = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -1)]),
new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.5 }),
);
laser.scale.z = LASER_LENGTH;
const dot = new THREE.Mesh(
new THREE.SphereGeometry(0.006, 12, 8),
new THREE.MeshBasicMaterial({ color: 0xffffff }),
);
dot.visible = false;
controller.add(laser, dot);
controller.userData = {
laser,
dot,
hover: null, // { object: panel, screen or handle, hit }
held: null, // what the controller carries
heldByTrigger: false, // carried by a bar (trigger) rather than the grip
resizing: null,
sliding: null,
buttons: [],
};
controller.addEventListener('connected', (event) => {
controller.userData.inputSource = event.data;
});
controller.addEventListener('disconnected', () => {
this.release(controller);
controller.userData.inputSource = null;
});
controller.addEventListener('selectstart', () => this.onTrigger(controller));
controller.addEventListener('selectend', () => {
controller.userData.sliding = null;
controller.userData.resizing = null;
if (controller.userData.heldByTrigger) this.release(controller);
});
controller.addEventListener('squeezestart', () => this.grab(controller));
controller.addEventListener('squeezeend', () => this.release(controller));
this.player.scene.add(controller);
return controller;
}
// Below the screen, tilted towards the viewer
resetPanelPosition(origin = new THREE.Vector3(), yaw = 0) {
const forward = new THREE.Vector3(-Math.sin(yaw), 0, -Math.cos(yaw));
this.panel.position.copy(origin).addScaledVector(forward, 1.0);
this.panel.position.y -= 0.45;
this.panel.rotation.set(-0.5, yaw, 0, 'YXZ');
}
// What the laser can hit: the panel, and the screen when the video is shown flat
targets() {
const targets = [];
if (this.panel.visible) targets.push(this.panel);
if (this.player.projection === 'FLAT') targets.push(this.player.screen);
return targets;
}
// Called every frame before rendering
update(delta) {
if (!this.renderer.xr.isPresenting) return;
this.layoutHandles();
for (const controller of this.controllers) this.updateController(controller, delta);
// handles light up while pointed at or in use
for (const handle of this.handles) {
const { handle: kind, target } = handle.userData;
const active = this.controllers.some(
({ userData: d }) =>
d.hover?.object === handle ||
(kind === 'move' && d.heldByTrigger && d.held === target) ||
(kind === 'resize' && d.resizing === target),
);
handle.material.opacity = active ? 1 : HANDLE_OPACITY;
}
if (this.panel.visible) this.draw();
}
// The handles follow their window's edges but keep their own size (and their gap to the
// window) when the window is scaled, like in the Steam Frame's UI
layoutHandles() {
const panelScale = this.panel.scale.x;
this.panelBar.scale.setScalar(1 / panelScale);
this.panelBar.position.set(0, -PANEL_HEIGHT / 2 - 0.04 / panelScale, 0.001);
const flat = this.player.projection === 'FLAT';
this.screenBar.visible = flat;
this.screenCorner.visible = flat;
if (!flat) return;
const { width, height } = this.screenSize();
const scale = this.player.screen.scale.x;
this.screenBar.scale.setScalar(1 / scale);
this.screenBar.position.set(0, -height / 2 - 0.07 / scale, 0.001);
this.screenCorner.scale.setScalar(1 / scale);
this.screenCorner.position.set(width / 2, -height / 2, 0.002);
}
screenSize() {
const mesh = this.player.meshes[0];
return mesh?.geometry.parameters ?? { width: 1, height: 1 };
}
updateController(controller, delta) {
const data = controller.userData;
const gamepad = data.inputSource?.gamepad;
if (gamepad) {
// A/X or B/Y, on the press only
for (const button of [BUTTON_A_X, BUTTON_B_Y]) {
const pressed = !!gamepad.buttons[button]?.pressed;
if (pressed && !data.buttons[button]) this.togglePanel();
data.buttons[button] = pressed;
}
}
this.raycaster.setFromXRController(controller);
if (data.resizing) this.resize(data.resizing, delta);
const hit = data.held || data.resizing ? null : this.raycaster.intersectObjects(this.targets(), true)[0];
data.hover = hit ? { object: this.hitTarget(hit.object), hit } : null;
const length = hit ? hit.distance : LASER_LENGTH;
data.laser.scale.z = length;
data.dot.visible = !!hit;
data.dot.position.set(0, 0, -length);
if (data.held) this.dragHeld(data, gamepad?.axes, delta);
if (data.sliding) {
const point = this.panelPoint(hit);
if (point) this.slide(data.sliding, point);
}
}
// The panel, the screen or a handle, for a mesh the laser hit
hitTarget(object) {
if (object.userData.handle) return object;
return object === this.panel ? this.panel : this.player.screen;
}
// Scales the screen around its centre so its bottom right corner follows the laser, eased
// like dragging
resize(screen, delta) {
const center = screen.getWorldPosition(new THREE.Vector3());
screen.getWorldQuaternion(this.quaternion);
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(this.quaternion);
this.plane.setFromNormalAndCoplanarPoint(normal, center);
const point = this.raycaster.ray.intersectPlane(this.plane, new THREE.Vector3());
if (!point) return;
const local = point.sub(center).applyQuaternion(this.quaternion.invert());
const { width, height } = this.screenSize();
const scale = Math.min(
MAX_SCALE,
Math.max(MIN_SCALE, Math.max(local.x / (width / 2), -local.y / (height / 2))),
);
const ease = 1 - Math.exp(-delta * SMOOTHING);
screen.scale.setScalar(THREE.MathUtils.lerp(screen.scale.x, scale, ease));
}
// Canvas pixel under a laser hit on the panel
panelPoint(hit) {
if (!hit || hit.object !== this.panel || !hit.uv) return null;
return { x: hit.uv.x * CANVAS_W, y: (1 - hit.uv.y) * CANVAS_H };
}
// The panel's layout; the passthrough button only shows in sessions that can do it
get areas() {
return this.player.passthroughAvailable ? AREAS_WITH_PASSTHROUGH : AREAS_WITHOUT_PASSTHROUGH;
}
areaAt(point) {
if (!point) return null;
for (const [name, area] of Object.entries(this.areas)) {
// sliders get a taller hit area than they're drawn
const pad = SLIDERS.has(name) ? 24 : 0;
if (
point.x >= area.x &&
point.x <= area.x + area.w &&
point.y >= area.y - pad &&
point.y <= area.y + area.h + pad
) {
return name;
}
}
return null;
}
onTrigger(controller) {
const hover = controller.userData.hover;
if (hover?.object.userData.handle === 'move') {
this.grab(controller, true);
return;
}
if (hover?.object.userData.handle === 'resize') {
controller.userData.resizing = hover.object.userData.target;
return;
}
if (hover?.object === this.player.screen && hover.hit.uv) {
this.clickScreen(hover.hit.uv.x);
return;
}
if (!this.panel.visible) {
this.showPanel();
return;
}
const point = this.panelPoint(controller.userData.hover?.hit);
const area = this.areaAt(point);
if (!area) return;
if (SLIDERS.has(area)) {
controller.userData.sliding = area;
this.slide(area, point);
return;
}
this.activate(area);
}
// Clicks on the flat screen work like YouTube on a phone: a click plays/pauses, a double
// click on the left or right third skips back or forward, and every further click there
// skips again. A click on a side waits briefly to see whether it's a double click.
clickScreen(x) {
const zone = x < 1 / 3 ? 'back' : x > 2 / 3 ? 'forward' : null;
const now = performance.now();
const last = this.lastScreenClick;
this.lastScreenClick = { time: now, zone };
clearTimeout(this.screenClickTimer);
if (zone && last?.zone === zone && now - last.time < DOUBLE_CLICK_MS) {
this.activate(zone);
} else if (zone) {
this.screenClickTimer = setTimeout(() => this.activate('play'), DOUBLE_CLICK_MS);
} else {
this.activate('play');
}
}
activate(area) {
const video = this.video;
switch (area) {
case 'play':
if (video.paused) video.play().catch(() => {});
else video.pause();
break;
case 'back':
video.currentTime = Math.max(0, video.currentTime - SKIP_SECONDS);
break;
case 'forward':
video.currentTime = Math.min(video.duration || Infinity, video.currentTime + SKIP_SECONDS);
break;
case 'mute':
video.muted = !video.muted;
break;
case 'previousProjection':
case 'nextProjection': {
const ids = PROJECTION_LABELS.map(([id]) => id);
const step = area === 'nextProjection' ? 1 : -1;
const index = (ids.indexOf(this.player.projection) + step + ids.length) % ids.length;
this.player.setProjection(ids[index]);
break;
}
case 'passthrough':
if (this.player.passthroughAvailable) this.player.setPassthrough(!this.player.passthrough);
break;
case 'recenter':
this.recenter();
break;
case 'exit':
this.renderer.xr.getSession()?.end();
break;
}
}
slide(area, point) {
const { x, w } = this.areas[area];
const fraction = Math.min(1, Math.max(0, (point.x - x) / w));
if (area === 'seek') {
if (Number.isFinite(this.video.duration)) this.video.currentTime = fraction * this.video.duration;
} else {
this.video.volume = fraction;
this.video.muted = fraction === 0;
}
}
togglePanel() {
if (this.panel.visible) this.panel.visible = false;
else this.showPanel();
}
// Shows the panel, bringing it back in front of the viewer if it was moved out of view
showPanel() {
this.panel.visible = true;
const { position, direction, yaw } = this.headPose();
const toPanel = this.panel.getWorldPosition(new THREE.Vector3()).sub(position);
const distance = toPanel.length();
const angle = toPanel.angleTo(direction);
if (angle > 0.8 || distance < 0.3 || distance > 4) this.resetPanelPosition(position, yaw);
}
// Where the viewer's head is and where it looks
headPose() {
const head = this.renderer.xr.getCamera();
const position = new THREE.Vector3().setFromMatrixPosition(head.matrixWorld);
const direction = new THREE.Vector3(0, 0, -1).transformDirection(head.matrixWorld);
return { position, direction, yaw: Math.atan2(-direction.x, -direction.z) };
}
// Picks up what the laser points at (a handle picks up its window). It then hangs on the
// laser at the same distance, held at the point where it was grabbed.
grab(controller, byTrigger = false) {
const hover = controller.userData.hover;
const target = hover?.object.userData.target ?? hover?.object;
if (!target || this.controllers.some((c) => c.userData.held === target)) return;
Object.assign(controller.userData, {
held: target,
heldByTrigger: byTrigger,
sliding: null,
resizing: null,
grabDistance: hover.hit.distance,
grabPoint: target.worldToLocal(hover.hit.point.clone()), // in the target's unscaled units
grabScale: target.scale.x,
});
}
release(controller) {
controller.userData.held = null;
controller.userData.heldByTrigger = false;
}
// Moves a held window along the laser, turned to face the viewer: the screen stays upright,
// the panel also tilts towards the viewer, neither rolls. The thumbstick moves it
// away/closer (forward/back) and resizes it (left/right). It eases towards where the
// controller says, which smooths out the hand's and the tracking's jitter.
dragHeld(data, axes, delta) {
const object = data.held;
const push = -(axes?.[AXIS_STICK_Y] || 0);
const grow = axes?.[AXIS_STICK_X] || 0;
if (Math.abs(push) > 0.2) {
data.grabDistance = Math.min(20, Math.max(0.3, data.grabDistance * (1 + push * delta * 1.5)));
}
if (Math.abs(grow) > 0.2) {
data.grabScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, data.grabScale * (1 + grow * delta)));
}
const ray = this.raycaster.ray;
const point = ray.origin.clone().addScaledVector(ray.direction, data.grabDistance);
const head = this.headPose().position;
this.facing.position.copy(point);
this.facing.lookAt(head.x, object === this.panel ? head.y : point.y, head.z);
// where the object's origin goes so the grabbed point lands on the laser
const offset = data.grabPoint.clone().multiplyScalar(data.grabScale).applyQuaternion(this.facing.quaternion);
const position = point.sub(offset);
const ease = 1 - Math.exp(-delta * SMOOTHING);
object.position.lerp(position, ease);
object.quaternion.slerp(this.facing.quaternion, ease);
object.scale.setScalar(THREE.MathUtils.lerp(object.scale.x, data.grabScale, ease));
data.laser.scale.z = data.grabDistance;
}
// Puts the screen, the panel and the front of 360°/180° videos where the viewer looks
recenter() {
const { position, yaw } = this.headPose();
this.player.placeScreen(position, yaw);
this.player.world.position.copy(position);
this.player.world.rotation.set(0, yaw, 0);
this.resetPanelPosition(position, yaw);
}
// Styled after the Steam Frame UI: dark slate panel, a lighter button with a blue underline
// for the focused control, thin sliders with a blue fill and a white knob
draw() {
const video = this.video;
const hovered = new Set(
this.controllers.map((c) => this.areaAt(this.panelPoint(c.userData.hover?.hit))).filter(Boolean),
);
// only redraw when something visible changed
const state = [
Math.floor(video.currentTime),
Math.floor(video.duration),
video.paused,
video.muted,
video.volume.toFixed(2),
this.player.projection,
this.player.passthrough,
this.player.passthroughAvailable,
[...hovered].join(),
Math.floor(video.buffered.length ? video.buffered.end(video.buffered.length - 1) : 0),
].join('|');
if (state === this.drawnState) return;
this.drawnState = state;
const c = this.context;
const areas = this.areas;
const roundRect = (x, y, w, h, r, color) => {
c.fillStyle = color;
c.beginPath();
c.roundRect(x, y, w, h, r);
c.fill();
};
const text = (
value,
x,
y,
{ size = 28, color = THEME.text, align = 'center', weight = 500, spacing = 0 } = {},
) => {
c.font = `${weight} ${size}px ${THEME.font}`;
c.letterSpacing = `${spacing}px`;
c.textAlign = align;
c.textBaseline = 'middle';
c.fillStyle = color;
c.fillText(value, x, y);
c.letterSpacing = '0px';
};
// focused control: lighter background with a short blue line centred at the bottom
const focus = (x, y, w, h) => {
roundRect(x, y, w, h, 4, THEME.focus);
const line = w * 0.5;
roundRect(x + (w - line) / 2, y + h - 10, line, 5, 2.5, THEME.accent);
};
// filled: a grey button; active: a switched-on toggle, drawn blue
const iconButton = (name, drawIcon, { filled = false, active = false } = {}) => {
const { x, y, w, h } = areas[name];
if (hovered.has(name)) focus(x, y, w, h);
else if (filled) roundRect(x, y, w, h, 4, THEME.button);
c.save();
c.translate(x + w / 2, y + h / 2);
const color = active ? THEME.accent : THEME.text;
c.fillStyle = color;
c.strokeStyle = color;
c.lineWidth = 4;
c.lineCap = 'round';
c.lineJoin = 'round';
drawIcon();
c.restore();
};
const slider = (name, fraction, buffered = 0) => {
const { x, y, w, h } = areas[name];
const mid = y + h / 2;
roundRect(x, mid - 4, w, 8, 4, THEME.track);
if (buffered) roundRect(x, mid - 4, w * buffered, 8, 4, THEME.buffered);
roundRect(x, mid - 4, w * fraction, 8, 4, THEME.accent);
c.fillStyle = '#fff';
c.beginPath();
c.arc(x + w * fraction, mid, 20, 0, Math.PI * 2);
c.fill();
};
const speaker = (muted) => {
c.beginPath();
c.moveTo(-22, -8);
c.lineTo(-12, -8);
c.lineTo(0, -20);
c.lineTo(0, 20);
c.lineTo(-12, 8);
c.lineTo(-22, 8);
c.closePath();
c.fill();
c.beginPath();
if (muted) {
c.moveTo(10, -9);
c.lineTo(26, 9);
c.moveTo(26, -9);
c.lineTo(10, 9);
} else {
c.arc(2, 0, 12, -0.9, 0.9);
c.moveTo(2 + 22 * Math.cos(-0.9), 22 * Math.sin(-0.9));
c.arc(2, 0, 22, -0.9, 0.9);
}
c.stroke();
};
// circular arrow around the skip seconds, open at the top, pointing back or forward
const skipIcon = (direction) => {
const gapStart = -Math.PI / 2 - 0.5;
const gapEnd = -Math.PI / 2 + 0.5;
c.beginPath();
c.arc(0, 0, 24, gapEnd, gapStart);
c.stroke();
// arrowhead on the end the arrow turns towards
const tipAngle = direction < 0 ? gapEnd : gapStart;
const tx = 24 * Math.cos(tipAngle);
const ty = 24 * Math.sin(tipAngle);
c.beginPath();
c.moveTo(tx - 9 * direction, ty - 7);
c.lineTo(tx, ty);
c.lineTo(tx - 9 * direction, ty + 7);
c.stroke();
text(String(SKIP_SECONDS), 0, 2, { size: 18, weight: 700 });
};
c.clearRect(0, 0, CANVAS_W, CANVAS_H);
roundRect(0, 0, CANVAS_W, CANVAS_H, 12, THEME.panel);
// row 1: time, seek bar, duration; the whole row lights up when focused, like a
// Quick Settings slider row
const duration = video.duration;
const seekable = Number.isFinite(duration) && duration > 0;
const seekMid = areas.seek.y + areas.seek.h / 2;
if (hovered.has('seek')) roundRect(16, ROW_TOP - 8, CANVAS_W - 32, 96, 4, THEME.focus);
text(formatTime(video.currentTime), 80, seekMid, { color: THEME.label });
text(seekable ? formatTime(duration) : 'LIVE', 1200, seekMid, {
color: THEME.label,
spacing: seekable ? 0 : 2,
});
const buffered =
video.buffered.length && seekable ? video.buffered.end(video.buffered.length - 1) / duration : 0;
slider('seek', seekable ? video.currentTime / duration : 1, buffered);
roundRect(16, ROW_BOTTOM - 14, CANVAS_W - 32, 2, 1, THEME.separator);
// row 2: transport, volume, projection, recenter, exit
iconButton('back', () => skipIcon(-1));
iconButton('play', () => {
c.beginPath();
if (video.paused) {
c.moveTo(-13, -20);
c.lineTo(20, 0);
c.lineTo(-13, 20);
c.closePath();
c.fill();
} else {
c.fillRect(-15, -19, 10, 38);
c.fillRect(5, -19, 10, 38);
}
});
iconButton('forward', () => skipIcon(1));
iconButton('mute', () => speaker(video.muted || video.volume === 0));
if (hovered.has('volume')) {
const { x, w } = areas.volume;
roundRect(x - 20, ROW_BOTTOM, w + 40, ROW_HEIGHT, 4, THEME.focus);
}
slider('volume', video.muted ? 0 : video.volume);
iconButton('previousProjection', () => text('‹', 0, -3, { size: 52, weight: 400 }));
iconButton('nextProjection', () => text('›', 0, -3, { size: 52, weight: 400 }));
const label = PROJECTION_LABELS.find(([id]) => id === this.player.projection)?.[1] ?? '';
const labelX = (areas.previousProjection.x + areas.previousProjection.w + areas.nextProjection.x) / 2;
text('PROJECTION', labelX, ROW_BOTTOM + 26, { size: 18, color: THEME.label, weight: 600, spacing: 2 });
text(label, labelX, ROW_BOTTOM + 60, { size: 24 });
// passthrough, only in sessions that can show the room: a headset with the room's
// horizon showing through its visor
if (areas.passthrough) {
iconButton(
'passthrough',
() => {
c.beginPath();
c.roundRect(-26, -15, 52, 30, 10);
c.stroke();
c.beginPath();
c.moveTo(-14, 5);
c.lineTo(-5, -4);
c.lineTo(3, 3);
c.lineTo(8, -2);
c.lineTo(14, 5);
c.stroke();
},
{ filled: true, active: this.player.passthrough },
);
}
// recenter: a target
iconButton(
'recenter',
() => {
c.beginPath();
c.arc(0, 0, 14, 0, Math.PI * 2);
c.moveTo(0, -24);
c.lineTo(0, -8);
c.moveTo(0, 8);
c.lineTo(0, 24);
c.moveTo(-24, 0);
c.lineTo(-8, 0);
c.moveTo(8, 0);
c.lineTo(24, 0);
c.stroke();
},
{ filled: true },
);
// exit VR: an arrow out of a door
iconButton(
'exit',
() => {
c.beginPath();
c.moveTo(2, -20);
c.lineTo(-20, -20);
c.lineTo(-20, 20);
c.lineTo(2, 20);
c.moveTo(-6, 0);
c.lineTo(22, 0);
c.moveTo(13, -9);
c.lineTo(22, 0);
c.lineTo(13, 9);
c.stroke();
},
{ filled: true },
);
this.texture.needsUpdate = true;
}
dispose() {
clearTimeout(this.screenClickTimer);
this.renderer.xr.removeEventListener('sessionstart', this.onSessionStart);
this.renderer.xr.removeEventListener('sessionend', this.onSessionEnd);
for (const controller of this.controllers) {
this.release(controller);
this.player.scene.remove(controller);
for (const child of [...controller.children]) {
child.geometry.dispose();
child.material.dispose();
controller.remove(child);
}
}
for (const handle of this.handles) {
handle.removeFromParent();
handle.geometry.dispose();
handle.material.map.dispose();
handle.material.dispose();
}
this.player.scene.remove(this.panel);
this.panel.geometry.dispose();
this.panel.material.dispose();
this.texture.dispose();
}
}
+119 -24
View File
@@ -1,5 +1,6 @@
import * as THREE from 'three';
import { VRButton } from 'three/addons/webxr/VRButton.js';
import { PROJECTION_LABELS } from './projections.js';
import { VRControls } from './vr-controls.js';
// Chrome exposes XRWebGLBinding.createProjectionLayer even when the session isn't granted
// the 'layers' feature. three.js only checks that the method exists, sets up a projection
@@ -20,24 +21,21 @@ const TOP_HALF = [0, 0.5, 1, 0.5],
// Each projection builds its meshes. Stereo projections put one mesh on layer 1 (left eye)
// and one on layer 2 (right eye); three.js renders those layers to the matching eye in VR.
export const PROJECTIONS = {
EAC: { label: '360° EAC (YouTube default)', build: (m) => [eacMesh(m)] },
EAC_LR: { label: '360° EAC 3D', build: (m) => eacStereoMeshes(m) },
CUBE: { label: '360° cubemap', build: (m) => [boxMesh(backSide(m), CUBE_FACES)] },
360: { label: '360° equirectangular', build: (m) => [sphereMesh(m, false, FULL_FRAME)] },
EAC: { build: (m) => [eacMesh(m)] },
EAC_LR: { build: (m) => eacStereoMeshes(m) },
CUBE: { build: (m) => [boxMesh(backSide(m), CUBE_FACES)] },
360: { build: (m) => [sphereMesh(m, false, FULL_FRAME)] },
'360_TB': {
label: '360° 3D top/bottom',
build: (m) => [sphereMesh(m, false, TOP_HALF, 1), sphereMesh(m, false, BOTTOM_HALF, 2)],
},
'360_LR': {
label: '360° 3D side by side',
build: (m) => [sphereMesh(m, false, LEFT_HALF, 1), sphereMesh(m, false, RIGHT_HALF, 2)],
},
180: { label: '180°', build: (m) => [sphereMesh(m, true, FULL_FRAME)] },
180: { build: (m) => [sphereMesh(m, true, FULL_FRAME)] },
'180_LR': {
label: '180° 3D side by side',
build: (m) => [sphereMesh(m, true, LEFT_HALF, 1), sphereMesh(m, true, RIGHT_HALF, 2)],
},
FLAT: { label: 'Flat screen', build: (m) => [flatScreenMesh(m)] },
FLAT: { build: (m) => [flatScreenMesh(m)] },
};
// Equirectangular sphere (or front hemisphere for 180°), centred on -Z where the viewer faces.
@@ -182,13 +180,14 @@ function eacStereoMeshes(material) {
];
}
// A 2m tall screen 3m in front of the viewer, for videos that are only available flat
// A 2m tall screen, for videos that are only available flat. VRPlayer puts it in its movable
// screen group, 3m in front of the viewer to start with.
const SCREEN_DISTANCE = 3;
const BACKGROUND = 0x101010;
function flatScreenMesh(material) {
const video = material.map.image;
const aspect = video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 16 / 9;
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2 * aspect, 2), material);
mesh.position.set(0, 0, -3);
return mesh;
return new THREE.Mesh(new THREE.PlaneGeometry(2 * aspect, 2), material);
}
// Renders a <video> element into a WebXR session with a switchable projection
@@ -198,7 +197,9 @@ export class VRPlayer {
this.projection = projection;
this.meshes = [];
this.camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 1, 2000);
// a near plane of a few centimetres, so the controls panel and a screen pulled close
// aren't clipped away (the XR session takes its depth range from this camera)
this.camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.05, 2000);
this.camera.layers.enable(1); // render left view when no stereo available
const texture = new THREE.VideoTexture(video);
@@ -208,18 +209,46 @@ export class VRPlayer {
this.material = new THREE.MeshBasicMaterial({ map: texture });
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x101010);
// In AR sessions the headset shows the room wherever the image is transparent. The
// render loop clears the frame itself (see render()), opaque dark grey normally and
// transparent for passthrough, because three.js would always clear to transparent there.
this.passthrough = false;
// AR sessions can show the headset's passthrough; asked early, so entering VR doesn't
// have to wait for the answer and lose the click's user activation
this.arSupported = false;
navigator.xr
?.isSessionSupported('immersive-ar')
.then((supported) => {
this.arSupported = supported;
})
.catch(() => {});
// the 360°/180° meshes, turned by "Recenter", and the flat screen, which can be grabbed
this.world = new THREE.Group();
this.screen = new THREE.Group();
this.placeScreen(new THREE.Vector3(), 0);
this.scene.add(this.world, this.screen);
this.setProjection(projection);
// the geometry depends on the video's aspect ratio, which can change with quality switches
this.onVideoResize = () => this.setProjection(this.projection);
video.addEventListener('resize', this.onVideoResize);
this.renderer = new THREE.WebGLRenderer();
// antialias also turns on multisampling for the headset's image (three.js passes it on
// to the XR layer), which smooths the edges of the screen, panel and lasers
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.xr.enabled = true;
this.renderer.xr.setReferenceSpaceType('local');
this.renderer.setAnimationLoop(() => this.renderer.render(this.scene, this.camera));
this.renderer.autoClear = false;
this.vrControls = new VRControls(this);
this.timer = new THREE.Timer();
this.renderer.setAnimationLoop((timestamp) => {
this.timer.update(timestamp);
this.vrControls.update(this.timer.getDelta());
this.render();
});
this.onSessionEnd = () => this.setPassthrough(false);
this.renderer.xr.addEventListener('sessionend', this.onSessionEnd);
this.onWindowResize = () => {
if (this.renderer.xr.isPresenting) return;
@@ -230,32 +259,95 @@ export class VRPlayer {
window.addEventListener('resize', this.onWindowResize);
}
// Starts an immersive session. Chrome only allows this from a user gesture in the page,
// so call it synchronously from a click handler. Where the browser supports AR, the
// session is an AR one, which looks the same until passthrough is turned on.
async enterVR() {
const mode = this.arSupported ? 'immersive-ar' : 'immersive-vr';
const session = await navigator.xr.requestSession(mode, {
optionalFeatures: ['local-floor', 'bounded-floor'],
});
await this.renderer.xr.setSession(session);
return session;
}
// Whether the current session can show the room around the video
get passthroughAvailable() {
return this.renderer.xr.isPresenting && this.renderer.xr.getEnvironmentBlendMode() === 'alpha-blend';
}
setPassthrough(on) {
this.passthrough = on;
}
// Clears the frame (in VR, the headset's image, which three.js binds before the animation
// loop runs): alpha 1 hides the room in AR sessions, alpha 0 lets it show through
render() {
this.renderer.setClearColor(BACKGROUND, this.passthrough ? 0 : 1);
this.renderer.clear();
this.renderer.render(this.scene, this.camera);
}
get canvas() {
return this.renderer.domElement;
}
setProjection(name) {
this.projection = name;
if (this.select) this.select.value = name;
this.disposeMeshes();
this.meshes = PROJECTIONS[name].build(this.material);
for (const mesh of this.meshes) this.scene.add(mesh);
const parent = name === 'FLAT' ? this.screen : this.world;
for (const mesh of this.meshes) parent.add(mesh);
}
// Puts the flat screen in front of a viewer at position, looking towards yaw
placeScreen(position, yaw) {
const forward = new THREE.Vector3(-Math.sin(yaw), 0, -Math.cos(yaw));
this.screen.position.copy(position).addScaledVector(forward, SCREEN_DISTANCE);
this.screen.rotation.set(0, yaw, 0);
this.screen.scale.setScalar(1);
}
// The Enter VR button and projection dropdown side by side, plus any extra buttons
createControls(...extraButtons) {
this.controls = document.createElement('div');
Object.assign(this.controls.style, { display: 'flex', justifyContent: 'center', gap: '8px' });
const vrButton = VRButton.createButton(this.renderer);
vrButton.style.position = 'static';
this.controls.append(vrButton, this.createProjectionSelect(), ...extraButtons);
this.controls.append(this.createVRButton(), this.createProjectionSelect(), ...extraButtons);
return this.controls;
}
// Like three.js' VRButton, but enters through enterVR(), which picks AR when available
createVRButton() {
const button = document.createElement('button');
styleControl(button);
button.style.width = '100px';
button.textContent = 'ENTER VR';
button.disabled = true;
const supported = (mode) => navigator.xr?.isSessionSupported(mode).catch(() => false) ?? false;
Promise.all([supported('immersive-vr'), supported('immersive-ar')]).then(([vr, ar]) => {
button.disabled = !vr && !ar;
if (button.disabled) button.textContent = 'VR NOT SUPPORTED';
});
button.onclick = () => {
const session = this.renderer.xr.getSession();
if (session) session.end();
else this.enterVR().catch((e) => console.warn('video2webxr: entering VR failed:', e));
};
this.renderer.xr.addEventListener('sessionstart', () => {
button.textContent = 'EXIT VR';
});
this.renderer.xr.addEventListener('sessionend', () => {
button.textContent = 'ENTER VR';
});
return button;
}
createProjectionSelect() {
const select = document.createElement('select');
select.id = 'VRProjectionSelect';
select.title = 'Video projection';
for (const [value, { label }] of Object.entries(PROJECTIONS)) {
for (const [value, label] of PROJECTION_LABELS) {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
@@ -265,12 +357,13 @@ export class VRPlayer {
select.value = this.projection;
styleControl(select);
select.onchange = () => this.setProjection(select.value);
this.select = select;
return select;
}
disposeMeshes() {
for (const mesh of this.meshes) {
this.scene.remove(mesh);
mesh.removeFromParent();
mesh.geometry.dispose();
if (mesh.material !== this.material) mesh.material.dispose();
}
@@ -281,11 +374,13 @@ export class VRPlayer {
const session = this.renderer.xr.getSession();
if (session) await session.end().catch(() => {});
this.renderer.setAnimationLoop(null);
this.vrControls.dispose();
this.video.removeEventListener('resize', this.onVideoResize);
window.removeEventListener('resize', this.onWindowResize);
this.disposeMeshes();
this.material.map.dispose();
this.material.dispose();
this.renderer.xr.removeEventListener('sessionend', this.onSessionEnd);
this.canvas.remove();
if (this.controls) this.controls.remove();
this.renderer.dispose();
+23
View File
@@ -0,0 +1,23 @@
// Runs in YouTube's own JavaScript world, where the player's API is reachable, and answers
// youtube.js' requests for the current video's projection data. Content scripts can't see
// page objects, so the two sides talk through DOM events; the data goes as a JSON string
// because objects don't cross between the worlds.
document.addEventListener('video2webxr-info-request', () => {
let info = null;
try {
const player = document.getElementById('movie_player');
const response = player?.getPlayerResponse?.() || window.ytInitialPlayerResponse;
if (response) {
const formats = response.streamingData?.adaptiveFormats || [];
info = {
videoId: response.videoDetails?.videoId,
projections: [...new Set(formats.map((f) => f.projectionType).filter(Boolean))],
partialSpherical: !!response.playerConfig?.vrConfig?.partialSpherical,
};
}
} catch {
// no player yet; youtube.js falls back to its default
}
document.dispatchEvent(new CustomEvent('video2webxr-info', { detail: JSON.stringify(info) }));
});
+75 -2
View File
@@ -1,4 +1,5 @@
import cardboardSvg from '../icons/cardboard.svg';
import { halvesCorrelation, STEREO_THRESHOLD } from './stereo.js';
import { VRPlayer } from './vr-player.js';
let player = null;
@@ -60,6 +61,77 @@ videoElements.forEach((video) => {
// Start observing the entire document for changes
observer.observe(document, { childList: true, subtree: true });
// Asks youtube-page.js, which can reach YouTube's player, for the video's projection data.
// DOM events are dispatched synchronously, so the answer is there when dispatchEvent returns.
function playerInfo() {
let info = null;
const onInfo = (event) => {
info = JSON.parse(event.detail);
};
document.addEventListener('video2webxr-info', onInfo);
document.dispatchEvent(new CustomEvent('video2webxr-info-request'));
document.removeEventListener('video2webxr-info', onInfo);
return info;
}
// The projection YouTube says the video has. YouTube serves both 360° and VR180 videos as
// "MESH" on desktop; VR180 ones are marked partialSpherical.
function detectProjection() {
const info = playerInfo();
console.log('video2webxr: YouTube player info', info);
if (!info) return 'EAC';
const is180 = info.partialSpherical;
if (info.projections.includes('MESH')) return is180 ? '180' : 'EAC';
if (info.projections.includes('EQUIRECTANGULAR')) return is180 ? '180' : '360';
return 'FLAT';
}
// YouTube doesn't say whether a video is 3D, so look at the frame: the two eyes' views sit
// side by side (or above each other) and look nearly the same
const STEREO_VARIANTS = {
EAC: [['LR', 'EAC_LR']],
360: [
['TB', '360_TB'],
['LR', '360_LR'],
],
180: [['LR', '180_LR']],
};
const STEREO_CHECK_MS = 200; // a check scales one frame down to 64x64, so it's cheap
const STEREO_MAX_CHECKS = 60000 / STEREO_CHECK_MS;
// Samples frames until two agree on the layout, skipping ads (they play in the same <video>)
// and frames too dark or plain to tell
function detectStereo(vrPlayer, mono, votes = {}, attempt = 0) {
const variants = STEREO_VARIANTS[mono];
// stop once the player is gone or the user picked a projection themselves
if (!variants || player !== vrPlayer || vrPlayer.projection !== mono) return;
const adShowing = document.querySelector('#movie_player')?.classList.contains('ad-showing');
if (!adShowing) {
let vote = null;
for (const [split, stereo] of variants) {
const similarity = halvesCorrelation(vrPlayer.video, split);
if (similarity === null) continue;
console.log(`video2webxr: ${split} halves correlation ${similarity.toFixed(2)}`);
if (similarity > STEREO_THRESHOLD) {
vote = stereo;
break;
}
vote ??= mono;
}
if (vote) {
votes[vote] = (votes[vote] || 0) + 1;
if (votes[vote] === 2) {
vrPlayer.setProjection(vote);
return;
}
}
}
// give up after a minute of the actual video (ads don't count) without a clear answer
const checked = adShowing ? attempt : attempt + 1;
if (checked < STEREO_MAX_CHECKS) setTimeout(() => detectStereo(vrPlayer, mono, votes, checked), STEREO_CHECK_MS);
}
function EnableVRVideo(videoElement) {
console.log(`enabling VR on ${videoElement}`);
@@ -73,8 +145,9 @@ function EnableVRVideo(videoElement) {
spherecontrol.remove();
}
// EAC is what YouTube serves 360° videos as on desktop
player = new VRPlayer(videoElement, 'EAC');
const projection = detectProjection();
player = new VRPlayer(videoElement, projection);
detectStereo(player, projection);
const container = document.querySelector('.html5-video-container');
container.appendChild(player.canvas);
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB