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>
This commit is contained in:
phitandClaude Opus 5.5 committed 2026-10-06 00:20:33 +02:00
1 parent e09b79fb00
commit 471f7ee2c1
11 files changed
+439 -138

No files matched your search

+7 -4
View File
@@ -26,10 +26,11 @@ A Chromium extension that plays web videos in your PC VR headset through WebXR,
**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 page itself gets no extra controls, so video lightboxes and overlays keep working.
### Projections
@@ -48,7 +49,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 +58,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
+2 -4
View File
@@ -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": {
+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;
}),
);
}
+190 -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,200 @@ 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
}
return null;
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 start(video) {
const player = new VRPlayer(video, DEFAULT_PROJECTION);
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;
const closeButton = document.createElement('button');
closeButton.textContent = 'CLOSE';
styleControl(closeButton);
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);
}
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 hideHint() {
hint?.remove();
hint = null;
}
const close = () => {
console.log('disabling VR');
function isOnVideo(event) {
const rect = video.getBoundingClientRect();
return (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
);
}
// capture phase, so the site doesn't also see the click (and e.g. pause the video)
function onClick(event) {
if (!isOnVideo(event)) return;
event.preventDefault();
event.stopPropagation();
window.removeEventListener('click', onClick, true);
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();
lastError = `Entering VR failed: ${e.message}`;
});
}
function stop() {
window.removeEventListener('click', onClick, true);
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);
window.addEventListener('click', onClick, 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