Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21630273ab | ||
|
|
416f64d9d1 | ||
|
|
0d0a2b33ba | ||
|
|
ca98a5ed16 | ||
|
|
859de21258 | ||
|
|
cfe82d49bc | ||
|
|
e1e997cf95 | ||
|
|
eca0d9c101 | ||
|
|
b0f2a3353e | ||
|
|
724125b138 | ||
|
|
9faae241a0 | ||
|
|
409c9d91ed | ||
|
|
e5b6ac3df7 | ||
|
|
b7c8e863d7 | ||
|
|
454944a7aa | ||
|
|
2c5683d0f3 | ||
|
|
150ee9a0aa | ||
|
|
28d182f5d4 | ||
|
|
5051e164d1 | ||
|
|
c587686bca | ||
|
|
87dd16d6d1 | ||
|
|
471f7ee2c1 |
No files matched your search
@@ -2,65 +2,113 @@
|
||||
|
||||
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
|
||||
|
||||

|
||||

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

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

|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
|
After Width: | Height: | Size: 153 KiB |
|
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,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": {
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
['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';
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) }));
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |