Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
150ee9a0aa | ||
|
|
28d182f5d4 | ||
|
|
5051e164d1 | ||
|
|
c587686bca | ||
|
|
87dd16d6d1 | ||
|
|
471f7ee2c1 |
No files matched your search
@@ -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
|
||||
|
||||

|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
**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"
|
||||
|
||||

|
||||
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 +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
|
||||
|
||||
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -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": {
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
|
||||
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |