6 Commits
Author SHA1 Message Date
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
18 changed files with 489 additions and 143 deletions

No files matched your search

+12 -5
View File
@@ -5,7 +5,7 @@ A Chromium extension that plays web videos in your PC VR headset through WebXR,
- **YouTube:** 360°, VR180 and 3D videos, via a cardboard icon in the player
- **Other sites:** any non-DRM video, via the extension's toolbar button
![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.
@@ -23,13 +23,18 @@ A Chromium extension that plays web videos in your PC VR headset through WebXR,
3. Pick the video's projection in the dropdown next to the "Enter VR" button
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 180° 3D side by side; 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. Open the popup again to change the projection while in VR, 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.
### Projections
@@ -48,7 +53,7 @@ You can switch the projection while in VR. If the image looks jumbled into squar
### 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
@@ -57,6 +62,8 @@ 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 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
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

+3 -5
View File
@@ -1,6 +1,6 @@
{
"name": "video2webxr",
"version": "2.0.0",
"version": "2.1.0",
"description": "Watch YouTube 360°/VR180 videos and videos on other sites in your PC VR headset via WebXR",
"manifest_version": 3,
"content_scripts": [
@@ -9,9 +9,6 @@
"js": ["script.js"]
}
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "Watch this video in VR",
"default_icon": {
@@ -19,7 +16,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.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "video2webxr",
"version": "2.0.0",
"version": "2.1.0",
"license": "ISC",
"dependencies": {
"three": "^0.186.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "video2webxr",
"version": "2.0.0",
"version": "2.1.0",
"description": "Watch YouTube 360°/VR180 videos and videos on other sites in a PC VR headset via WebXR",
"main": "src/youtube.js",
"scripts": {
+3 -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,7 @@ 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', 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 (YouTube default)'],
['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 VR videos outside YouTube are 180° side by side
export const DEFAULT_PROJECTION = '180_LR';
+18 -10
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';
// 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.
@@ -230,6 +228,16 @@ 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.
async enterVR() {
const session = await navigator.xr.requestSession('immersive-vr', {
optionalFeatures: ['local-floor', 'bounded-floor'],
});
await this.renderer.xr.setSession(session);
return session;
}
get canvas() {
return this.renderer.domElement;
}
@@ -255,7 +263,7 @@ export class VRPlayer {
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;
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