1
0

34 Commits

Author SHA1 Message Date
Aiden
a470d4bdc7 additions and refactors
All checks were successful
Test / test (push) Successful in 9m30s
2026-06-11 05:27:20 +10:00
Aiden
ea184ba448 More reffactors 2026-06-10 17:23:06 +10:00
Aiden
707cad3719 Loop and other fixes
All checks were successful
Test / test (push) Successful in 9m37s
2026-06-10 16:17:08 +10:00
Aiden
857c9ac980 carosel images
All checks were successful
Test / test (push) Successful in 9m32s
2026-06-10 15:12:25 +10:00
Aiden
c28386ccdd New screen behaviour
All checks were successful
Test / test (push) Successful in 9m33s
2026-06-10 14:58:26 +10:00
Aiden
ba3c2785d8 Hand tracking
Some checks failed
Test / test (push) Has been cancelled
2026-06-10 14:54:56 +10:00
Aiden
c1fbfd3b5e hand adjsutments
All checks were successful
Test / test (push) Successful in 9m30s
2026-06-10 14:32:47 +10:00
Aiden
5397bf1a5c Create index.html
Some checks failed
Test / test (push) Has been cancelled
2026-06-10 14:29:23 +10:00
Aiden
82d5c31ab2 clean up and hand tracking
Some checks failed
Test / test (push) Has been cancelled
2026-06-10 14:29:15 +10:00
Aiden
95b9bc7cdc added VR180 test
All checks were successful
Test / test (push) Successful in 9m26s
2026-06-10 13:18:35 +10:00
Aiden
0879f1685a status banner
All checks were successful
Test / test (push) Successful in 9m26s
2026-06-10 12:56:57 +10:00
Aiden
8402fcd640 new test hub
Some checks failed
Test / test (push) Has been cancelled
2026-06-10 12:51:31 +10:00
Aiden
030a8b724b added image support 2026-06-10 12:48:36 +10:00
Aiden
24a166046e more refactors
All checks were successful
Test / test (push) Successful in 9m32s
2026-06-10 12:37:48 +10:00
Aiden
d9a5ec9018 change bind address 2026-06-10 12:32:33 +10:00
Aiden
481ca9fc47 more refactor
All checks were successful
Test / test (push) Successful in 9m26s
2026-06-10 12:27:12 +10:00
Aiden
ca577d2e92 CI/CD
All checks were successful
Test / test (push) Successful in 10m27s
2026-06-10 11:58:18 +10:00
Aiden
a254dca518 Added tests in 2026-06-10 11:57:23 +10:00
Aiden
74706a166a Folder organisation 2026-06-10 11:55:14 +10:00
Aiden
f5c82d3b78 more refactoer 2026-06-10 11:51:34 +10:00
Aiden
cb332abd4f Further refactor 2026-06-10 11:37:02 +10:00
Aiden
899027e531 Seperation 2026-06-10 11:26:29 +10:00
Aiden
fd82e1666f npm testing 2026-06-10 11:03:43 +10:00
Aiden
7265842deb UI update 2026-06-10 10:53:06 +10:00
Aiden
36986ae639 removed built files 2026-06-10 10:37:40 +10:00
Aiden
d24e2021f2 Typescript conversion 2026-06-10 10:35:14 +10:00
Aiden
91b612785b Initial build 2026-06-10 10:19:03 +10:00
Verdi
3bd2c135a9 update demo page 2026-02-19 13:12:49 -06:00
Verdi
2194a4726e Force GitHub Pages rebuild 2026-02-19 13:09:41 -06:00
Verdi
858eb62947 Update CDN urls in readme 2026-02-19 12:32:47 -06:00
Michael Verdi
d52a722ce7 Merge pull request #5
Resolve image paths relative to script URL for CDN support
2026-02-19 12:17:20 -06:00
Verdi
40e3711925 Resolve image paths relative to script URL for CDN support 2026-02-19 12:15:36 -06:00
Michael Verdi
c41ad12b32 Update index.html
Updated to use jsdelivr URLs
2026-02-19 11:23:08 -06:00
Michael Verdi
dbe6e5b1d9 Update README.md
Added jsdelivr.net CDN links
2026-02-19 11:21:45 -06:00
76 changed files with 7607 additions and 1945 deletions

28
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,28 @@
name: Test
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Type-check
run: npm run check
- name: Run tests
run: npm test

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
# Generated by `npm run build`.
vr180player/*.css
vr180player/*.js
vr180player/**/*.js
/media

107
README.md
View File

@@ -1,38 +1,95 @@
# VR180 Web Player
A web-based video player for 180 degree, 3D video.
# VR Web Player
A CDN-friendly web player for side-by-side stereoscopic video and still images.
Got an immersive video you want people to see with the Apple Vision Pro or Meta Quest headsets? Now you can put it on your website just like any other video! People will see the immersive 3D video if they have a capable headset or they'll get a 2D version on other devices.
The player supports two projection modes:
- `vr180`: an immersive 180 degree stereoscopic hemisphere in WebXR, with a draggable rectilinear fallback on non-XR browsers.
- `plane`: a flat stereoscopic media plane in WebXR, with a normal flat left-eye fallback on non-XR browsers.
## How to use it
1. Drop the `vr180player` directory in the root level of your website.
2. Link to the player CSS file `<link rel="stylesheet" href="vr180player/vr180-player.css">`.
3. Add the player script `<script type="module" src="vr180player/vr180-player.js"></script>` before the closing body tag.
4. And use this HTML snippet to embed your video:
Build the player first, then host the generated `vr180player/` directory on your CDN and include the module script. The script automatically loads its matching CSS file from the same folder, and it imports its helper modules with relative module paths.
```html
<div data-vr-web-player data-projection="vr180">
<video poster="poster.jpg" title="Demo Video" crossorigin="anonymous" playsinline preload="metadata">
<source src="sbs-video.mp4" type="video/mp4">
</video>
</div>
<script type="module" src="https://cdn.example.com/vr180player/vr180-player.js"></script>
```
<div id="vr-container">
<video id="vr180" poster="poster.jpg" title="Demo Video" crossOrigin="anonymous" playsinline>
Use `data-projection="plane"` for flat 3D video on a rectangular plane:
```html
<div data-vr-web-player data-projection="plane">
<video poster="poster.jpg" title="Demo Video" crossorigin="anonymous" playsinline preload="metadata">
<source src="sbs-video.mp4" type="video/mp4">
</video>
</div>
```
Use `data-head-lock="auto|position|none"` to control positional comfort in immersive mode. It defaults to `auto`, which position-locks `vr180` media to the headset to avoid false 6DoF parallax, while leaving `plane` media fixed like a screen. Use `position` to force locking for either projection, or `none` to keep all media world-fixed.
Use an `img` element for a static SBS image:
```html
<div data-vr-web-player data-projection="plane">
<img src="sbs-image.png" alt="Demo image" title="Demo Image" crossorigin="anonymous">
</div>
```
Add `data-carousel` to an image player when you want previous/next controls for multiple SBS still images in one immersive session:
```html
<div data-vr-web-player data-projection="vr180" data-carousel>
<img src="first-sbs-image.png" alt="First image" title="First Image" crossorigin="anonymous">
<img src="second-sbs-image.png" alt="Second image" title="Second Image" crossorigin="anonymous">
</div>
```
Only one player is supported per page in this version. The player logs a clear console message and does not initialize if no `[data-vr-web-player]` container is found, if multiple containers are found, if the container does not contain exactly one supported video/image element, if an image carousel does not contain at least two images and no videos, or if `data-projection` is not `vr180` or `plane`.
## Media format
This version supports side-by-side media only:
- Video: 2:1 side-by-side video using H.264 or HEVC in an mp4 file.
- Image: side-by-side still images in browser-supported image formats such as PNG, JPEG, or WebP.
It does not support over-under, MV-HEVC, APMP, or `.aivu`.
## How it works
When the webpage loads, the video file is embeded normally, with a play button positioned over the poster frame. When the user clicks play, the player script checks if `navigator.xr` exists. If it does, a VR experience is initiated. If not, it builds a rectilinear, 2D view of your video and plays it that way.
When the page loads, the media is embedded normally with an entry button over it. When the user clicks the button, the player checks for `navigator.xr` and `immersive-vr` support.
**VR on Apple Vision Pro**
![vr](https://github.com/user-attachments/assets/c1097a4f-8712-4e6b-a233-a52d49cb261e)
**2D in Safari on Mac**
You can drag the 2D video around to see things outside the frame.
<img width="1000" height="793" alt="2d" src="https://github.com/user-attachments/assets/094d30b7-7175-44ba-a700-d333196f8bb3" />
## Video Format
**The player only supports 2:1, side-by-side video using either H.264 or HEVC in an mp4 file.** It does not support over-under, MV-HEVC, APMP, or .aivu.
## Support
I'm not a developer and I might not be able to help you if you run into problems, want to customize this, or add new features (not that I won't try). I'm releasing this with the [unlicense](https://unlicense.org/) so you're free to do anything at all with it. That said, if you have ideas or want to contribute code, I'd love to [hear](mailto:hello@michaelverdi.com) from you.
- In WebXR, `vr180` maps the left and right halves of the SBS media onto the matching eyes of a 180 degree sphere. In the default `auto` head-lock mode, the sphere follows headset position but not headset rotation.
- In WebXR, `plane` maps the left and right halves onto the matching eyes of a floating 16:9 plane.
- Outside WebXR, both modes render only the left half of the SBS media so viewers do not see the raw double image.
- Video controls include a loop toggle for indefinite replay.
- Static images show only applicable controls; playback, seek, and mute controls are video-only.
- Image carousels replace skip buttons with previous/next image controls, so you can change stills without leaving the immersive session.
- Controller pointers and lightweight hand/controller overlays appear after controller interaction, then fade away after a short idle period so they do not distract from viewing.
- Control icons are embedded from [Lucide](https://lucide.dev/) SVG definitions, so no PNG icon assets are required.
## Demo
**Test it out!**
Open [https://verdi.github.io/VR180-Web-Player/](https://verdi.github.io/VR180-Web-Player/) in a browser on your headset (or another device). Or check out this short film I made -> [Blandscape](https://michaelverdi.com/blandscape)
Run `npm run build`, then open `test-pages/index.html` through a local web server. The test hub links to focused pages for flat 3D image, VR180 3D image, image carousels, flat 3D video, and VR180 3D video. The root `index.html` redirects there for convenience.
For local experimentation, run:
```sh
npm run dev
```
This builds the TypeScript player once, then serves `index.html` with Vite at a local URL.
For headset testing, the page must be a secure context before the browser will expose immersive WebXR. A LAN URL such as `http://192.168.x.x:5173/` is useful for checking layout and media loading, but it will usually not show the headset's immersive VR prompt. Use an HTTPS URL with a trusted certificate, a trusted tunnel, or a deployed CDN/Pages URL for immersive testing.
## Development
The player source is TypeScript in `src/vr180player/`. Generated JavaScript files in `vr180player/` are ignored by git so CI/CD can build and publish them from source.
```sh
npm install
npm run dev
npm run build
```
Edit the TypeScript source files rather than generated JavaScript. A typical CI/CD publish step should run `npm ci`, `npm run build`, then publish `vr180player/` with its generated `.js` files and CSS.

View File

@@ -2,32 +2,27 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>VR180 Web Player</title>
<link rel="stylesheet" href="vr180player/vr180-player.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="0; url=./test-pages/">
<title>VR Web Player Tests</title>
<style>
body {
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-size: 1rem;
font-weight: normal;
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #151515;
background: #f4f4f2;
}
main {
max-width: 750px;
margin: auto;
a {
color: inherit;
font-weight: 650;
}
</style>
</head>
</head>
<body>
<main>
<h1>VR180 Web Player</h1>
<p>This is a web-based player for 180° stereoscopic video.</p>
<div id="vr-container">
<video id="vr180" poster="poster.jpg" title="Demo Video" crossOrigin="anonymous" playsinline>
<source src="sbs-video.mp4" type="video/mp4">
</video>
<!-- UI elements will be dynamically inserted here by JavaScript -->
</div>
</main>
<script type="module" src="vr180player/vr180-player.js"></script>
<p><a href="./test-pages/">Open VR Web Player test pages</a></p>
</body>
</html>

887
package-lock.json generated Normal file
View File

@@ -0,0 +1,887 @@
{
"name": "vr-web-player",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vr-web-player",
"version": "0.1.0",
"devDependencies": {
"typescript": "^5.8.3",
"vite": "^8.0.16"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.133.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rolldown": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.133.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.3",
"@rolldown/binding-darwin-arm64": "1.0.3",
"@rolldown/binding-darwin-x64": "1.0.3",
"@rolldown/binding-freebsd-x64": "1.0.3",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
"@rolldown/binding-linux-arm64-musl": "1.0.3",
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
"@rolldown/binding-linux-x64-gnu": "1.0.3",
"@rolldown/binding-linux-x64-musl": "1.0.3",
"@rolldown/binding-openharmony-arm64": "1.0.3",
"@rolldown/binding-wasm32-wasi": "1.0.3",
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
"@rolldown/binding-win32-x64-msvc": "1.0.3"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/vite": {
"version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.15",
"rolldown": "1.0.3",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "vr-web-player",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "npm run build && vite --host 0.0.0.0",
"build": "tsc && node scripts/copy-styles.mjs",
"check": "tsc --noEmit",
"test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.test.mjs tests/media-adapter.test.mjs tests/hand-aim.test.mjs tests/input-mode.test.mjs",
"preview": "npm run build && vite preview --host 127.0.0.1"
},
"devDependencies": {
"typescript": "^5.8.3",
"vite": "^8.0.16"
}
}

Binary file not shown.

16
scripts/copy-styles.mjs Normal file
View File

@@ -0,0 +1,16 @@
import { copyFile, mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
const styleCopies = [
{
from: join(rootDir, 'src', 'vr180player', 'styles', 'vr180-player.css'),
to: join(rootDir, 'vr180player', 'vr180-player.css')
}
];
await Promise.all(styleCopies.map(async ({ from, to }) => {
await mkdir(dirname(to), { recursive: true });
await copyFile(from, to);
}));

View File

@@ -0,0 +1,125 @@
import {
DEFAULT_HEAD_LOCK,
DEFAULT_PROJECTION,
PLAYER_SELECTOR,
type HeadLockMode,
type ProjectionMode,
VALID_HEAD_LOCKS,
VALID_PROJECTIONS
} from './config.js';
import { create2DControlPanel, createPlayButton, injectPlayerStyles } from './dom/dom.js';
import { createMediaAdapter, type SupportedMediaAdapter } from './media/media-adapter.js';
export type BootstrapContext = {
headLockMode: HeadLockMode;
mediaAdapter: SupportedMediaAdapter;
playButton: HTMLButtonElement;
playerContainer: HTMLElement;
projectionMode: ProjectionMode;
};
export function bootstrapPlayer(playerBase: string, onReady: (context: BootstrapContext) => void): void {
injectPlayerStyles(playerBase);
onDocumentReady(() => {
const containers = document.querySelectorAll<HTMLElement>(PLAYER_SELECTOR);
if (containers.length === 0) {
console.error(`VR_WEB_PLAYER_DOM: Expected exactly one ${PLAYER_SELECTOR} container, found none.`);
return;
}
if (containers.length > 1) {
console.warn(`VR_WEB_PLAYER_DOM: This version supports exactly one ${PLAYER_SELECTOR} container per page. Found ${containers.length}; no player was initialized.`);
return;
}
const playerContainer = containers[0];
playerContainer.classList.add('vrwp');
const configuredProjection = (playerContainer.dataset.projection || DEFAULT_PROJECTION).trim().toLowerCase();
if (!VALID_PROJECTIONS.has(configuredProjection as ProjectionMode)) {
console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-projection="${configuredProjection}". Use "vr180" or "plane".`);
return;
}
const configuredHeadLock = (playerContainer.dataset.headLock || DEFAULT_HEAD_LOCK).trim().toLowerCase();
if (!VALID_HEAD_LOCKS.has(configuredHeadLock as HeadLockMode)) {
console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-head-lock="${configuredHeadLock}". Use "auto", "position", or "none".`);
return;
}
const mediaAdapter = createMediaAdapter(playerContainer);
if (!mediaAdapter) {
console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain exactly one video/img, or multiple img elements with data-carousel.`);
return;
}
const playButton = createPlayButton();
playerContainer.appendChild(playButton);
playerContainer.appendChild(create2DControlPanel());
playButton.disabled = true;
mediaAdapter.bindLoadState({
onError: (event) => {
console.error(`VR_WEB_PLAYER_MEDIA: Failed to load ${mediaAdapter.kind} media.`, event);
playButton.disabled = true;
},
onReady: () => {
playButton.disabled = false;
}
});
mediaAdapter.load();
completeXrSupportCheck(playButton, () => {
onReady({
headLockMode: configuredHeadLock as HeadLockMode,
mediaAdapter,
playButton,
playerContainer,
projectionMode: configuredProjection as ProjectionMode
});
});
});
}
function onDocumentReady(callback: () => void): void {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', callback, { once: true });
return;
}
callback();
}
function completeXrSupportCheck(playButton: HTMLButtonElement, onComplete: () => void): void {
if (!navigator.xr) {
if (!window.isSecureContext) {
console.warn('VR_WEB_PLAYER_XR: Immersive WebXR requires a secure context. Serve the page over HTTPS, a trusted tunnel, or a deployed CDN URL to test in a headset.');
} else {
console.warn('VR_WEB_PLAYER_XR: navigator.xr is not available in this browser.');
}
markXrUnsupported(playButton);
onComplete();
return;
}
navigator.xr.isSessionSupported('immersive-vr').then((supported) => {
if (supported) {
playButton.dataset.xrSupported = 'true';
} else {
console.warn('VR_WEB_PLAYER_XR: immersive-vr is not supported by this browser/device.');
markXrUnsupported(playButton);
}
onComplete();
}).catch((err) => {
console.error('XR Support Check Error:', err);
markXrUnsupported(playButton);
onComplete();
});
}
function markXrUnsupported(playButton: HTMLButtonElement): void {
playButton.dataset.xrSupported = 'false';
playButton.disabled = false;
}

14
src/vr180player/config.ts Normal file
View File

@@ -0,0 +1,14 @@
export const PLAYER_SELECTOR = '[data-vr-web-player]';
export type ProjectionMode = 'vr180' | 'plane';
export type HeadLockMode = 'auto' | 'position' | 'none';
export const DEFAULT_PROJECTION: ProjectionMode = 'vr180';
export const DEFAULT_HEAD_LOCK: HeadLockMode = 'auto';
export const VALID_PROJECTIONS = new Set<ProjectionMode>(['vr180', 'plane']);
export const VALID_HEAD_LOCKS = new Set<HeadLockMode>(['auto', 'position', 'none']);
export const PLANE_WIDTH = 3.2;
export const PLANE_HEIGHT = PLANE_WIDTH * (9 / 16);
export const PLANE_DISTANCE = 3;
export const PLANE_2D_DISTANCE = 1.2;

114
src/vr180player/dom/dom.ts Normal file
View File

@@ -0,0 +1,114 @@
import { createLucideIcon, type LucideIconName } from './icons.js';
export function injectPlayerStyles(playerBase: string): void {
if (document.querySelector('link[data-vr-web-player-stylesheet]')) {
return;
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = playerBase + 'vr180-player.css';
link.dataset.vrWebPlayerStylesheet = 'true';
if (document.head) {
document.head.appendChild(link);
} else {
document.addEventListener('DOMContentLoaded', () => document.head.appendChild(link), { once: true });
}
}
export function createPlayButton(): HTMLButtonElement {
const playButton = document.createElement('button');
playButton.type = 'button';
playButton.className = 'vrwp-play-button';
playButton.setAttribute('aria-label', 'Open media');
playButton.appendChild(createLucideIcon('circle-play'));
return playButton;
}
export function create2DControlPanel(): HTMLDivElement {
const panel = document.createElement('div');
panel.className = 'vrwp-panel';
const status = document.createElement('div');
status.className = 'vrwp-status';
const videoTitle = document.createElement('p');
videoTitle.className = 'vrwp-video-title';
videoTitle.textContent = 'Title';
const progress = document.createElement('div');
progress.className = 'vrwp-progress';
const currentTime = document.createElement('p');
currentTime.className = 'vrwp-current-time';
currentTime.textContent = '00:00:00';
const bar = document.createElement('div');
bar.className = 'vrwp-bar';
const played = document.createElement('div');
played.className = 'vrwp-played';
bar.appendChild(played);
const totalTime = document.createElement('p');
totalTime.className = 'vrwp-total-time';
totalTime.textContent = '00:00:00';
progress.appendChild(currentTime);
progress.appendChild(bar);
progress.appendChild(totalTime);
status.appendChild(videoTitle);
status.appendChild(progress);
const controls = document.createElement('div');
controls.className = 'vrwp-controls';
const fullscreenBtn = createControlButton('vrwp-fullscreen', 'Toggle fullscreen', 'maximize');
const nav = document.createElement('div');
nav.className = 'vrwp-nav';
const backBtn = createControlButton('vrwp-back', 'Back 15 seconds', 'rotate-ccw', '15');
const play2Btn = createControlButton('vrwp-play-toggle', 'Play or pause', 'play');
const forwardBtn = createControlButton('vrwp-forward', 'Forward 15 seconds', 'rotate-cw', '15');
nav.appendChild(backBtn);
nav.appendChild(play2Btn);
nav.appendChild(forwardBtn);
const muteBtn = createControlButton('vrwp-mute', 'Toggle mute', 'volume-2');
const loopBtn = createControlButton('vrwp-loop', 'Loop video', 'repeat');
loopBtn.setAttribute('aria-pressed', 'false');
controls.appendChild(fullscreenBtn);
controls.appendChild(nav);
controls.appendChild(loopBtn);
controls.appendChild(muteBtn);
panel.appendChild(status);
panel.appendChild(controls);
return panel;
}
function createControlButton(className: string, label: string, iconName: LucideIconName, skipLabel?: string): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.className = className;
button.setAttribute('aria-label', label);
button.appendChild(createLucideIcon(iconName));
if (skipLabel) {
const labelEl = document.createElement('span');
labelEl.className = 'vrwp-skip-label';
labelEl.textContent = skipLabel;
button.appendChild(labelEl);
}
return button;
}

View File

@@ -0,0 +1,191 @@
export type LucideIconName =
| 'circle-play'
| 'play'
| 'pause'
| 'maximize'
| 'arrow-left'
| 'chevron-left'
| 'chevron-right'
| 'rotate-ccw'
| 'rotate-cw'
| 'repeat'
| 'volume-2'
| 'volume-x'
| 'log-out';
type IconAttrs = Record<string, string>;
type IconNode = readonly [tagName: string, attrs: IconAttrs];
const SVG_NS = 'http://www.w3.org/2000/svg';
const ICONS: Record<LucideIconName, readonly IconNode[]> = {
'circle-play': [
['circle', { cx: '12', cy: '12', r: '10' }],
['polygon', { points: '10 8 16 12 10 16 10 8' }]
],
play: [
['polygon', { points: '6 3 20 12 6 21 6 3' }]
],
pause: [
['rect', { x: '14', y: '4', width: '4', height: '16', rx: '1' }],
['rect', { x: '6', y: '4', width: '4', height: '16', rx: '1' }]
],
maximize: [
['path', { d: 'M8 3H5a2 2 0 0 0-2 2v3' }],
['path', { d: 'M21 8V5a2 2 0 0 0-2-2h-3' }],
['path', { d: 'M3 16v3a2 2 0 0 0 2 2h3' }],
['path', { d: 'M16 21h3a2 2 0 0 0 2-2v-3' }]
],
'arrow-left': [
['path', { d: 'm12 19-7-7 7-7' }],
['path', { d: 'M19 12H5' }]
],
'chevron-left': [
['path', { d: 'm15 18-6-6 6-6' }]
],
'chevron-right': [
['path', { d: 'm9 18 6-6-6-6' }]
],
'rotate-ccw': [
['path', { d: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' }],
['path', { d: 'M3 3v5h5' }]
],
'rotate-cw': [
['path', { d: 'M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8' }],
['path', { d: 'M21 3v5h-5' }]
],
repeat: [
['path', { d: 'm17 2 4 4-4 4' }],
['path', { d: 'M3 11v-1a4 4 0 0 1 4-4h14' }],
['path', { d: 'm7 22-4-4 4-4' }],
['path', { d: 'M21 13v1a4 4 0 0 1-4 4H3' }]
],
'volume-2': [
['path', { d: 'M11 4.702a1 1 0 0 0-1.664-.747L5.5 7.5H4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h1.5l3.836 3.545A1 1 0 0 0 11 19.298z' }],
['path', { d: 'M16 9a5 5 0 0 1 0 6' }],
['path', { d: 'M19.364 18.364a9 9 0 0 0 0-12.728' }]
],
'volume-x': [
['path', { d: 'M11 4.702a1 1 0 0 0-1.664-.747L5.5 7.5H4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h1.5l3.836 3.545A1 1 0 0 0 11 19.298z' }],
['line', { x1: '22', y1: '9', x2: '16', y2: '15' }],
['line', { x1: '16', y1: '9', x2: '22', y2: '15' }]
],
'log-out': [
['path', { d: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4' }],
['polyline', { points: '16 17 21 12 16 7' }],
['line', { x1: '21', y1: '12', x2: '9', y2: '12' }]
]
};
export function createLucideIcon(name: LucideIconName, className = 'vrwp-icon'): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('class', className);
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
for (const [tagName, attrs] of ICONS[name]) {
const node = document.createElementNS(SVG_NS, tagName);
for (const [key, value] of Object.entries(attrs)) {
node.setAttribute(key, value);
}
svg.appendChild(node);
}
return svg;
}
export function setLucideIcon(target: HTMLElement, name: LucideIconName): void {
const existingIcon = target.querySelector('.vrwp-icon');
if (existingIcon) {
existingIcon.replaceWith(createLucideIcon(name));
return;
}
target.prepend(createLucideIcon(name));
}
export function drawLucideIcon(
ctx: CanvasRenderingContext2D,
name: LucideIconName,
x: number,
y: number,
size: number,
color = '#ffffff',
strokeWidth = 2
): void {
ctx.save();
ctx.translate(x, y);
ctx.scale(size / 24, size / 24);
ctx.strokeStyle = color;
ctx.lineWidth = strokeWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (const [tagName, attrs] of ICONS[name]) {
drawIconNode(ctx, tagName, attrs);
}
ctx.restore();
}
function drawIconNode(ctx: CanvasRenderingContext2D, tagName: string, attrs: IconAttrs): void {
switch (tagName) {
case 'path':
ctx.stroke(new Path2D(attrs.d));
break;
case 'line':
ctx.beginPath();
ctx.moveTo(Number(attrs.x1), Number(attrs.y1));
ctx.lineTo(Number(attrs.x2), Number(attrs.y2));
ctx.stroke();
break;
case 'polyline':
case 'polygon':
drawPoints(ctx, attrs.points, tagName === 'polygon');
break;
case 'rect':
drawRect(ctx, attrs);
break;
case 'circle':
ctx.beginPath();
ctx.arc(Number(attrs.cx), Number(attrs.cy), Number(attrs.r), 0, Math.PI * 2);
ctx.stroke();
break;
}
}
function drawPoints(ctx: CanvasRenderingContext2D, pointsAttr: string, closePath: boolean): void {
const points = pointsAttr.trim().split(/\s+/).map((pair) => pair.split(',').map(Number));
if (points.length === 0) return;
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
for (const [x, y] of points.slice(1)) {
ctx.lineTo(x, y);
}
if (closePath) {
ctx.closePath();
}
ctx.stroke();
}
function drawRect(ctx: CanvasRenderingContext2D, attrs: IconAttrs): void {
const x = Number(attrs.x);
const y = Number(attrs.y);
const width = Number(attrs.width);
const height = Number(attrs.height);
const radius = Number(attrs.rx || 0);
ctx.beginPath();
if (radius > 0 && typeof ctx.roundRect === 'function') {
ctx.roundRect(x, y, width, height, radius);
} else {
ctx.rect(x, y, width, height);
}
ctx.stroke();
}

View File

@@ -0,0 +1,277 @@
import { setLucideIcon } from './icons.js';
import { formatTime } from '../utils/time.js';
import type { MediaCapabilities } from '../media/media-adapter.js';
type TwoDControlPanelCallbacks = {
getIsLooping: () => boolean;
onForward: () => void;
onMute: () => void;
onPlayPause: () => void;
onRewind: () => void;
onSeek: (progress: number) => void;
onToggleLoop: () => boolean;
};
type TwoDControlPanelOptions = {
callbacks: TwoDControlPanelCallbacks;
fullscreenTarget: HTMLElement;
getIsActive: () => boolean;
mediaCapabilities: MediaCapabilities;
playerContainer: HTMLElement;
title: string;
};
const CONTROL_PANEL_HIDE_DELAY = 3000;
export class TwoDControlPanel {
private readonly callbacks: TwoDControlPanelCallbacks;
private readonly fullscreenTarget: HTMLElement;
private readonly getIsActive: () => boolean;
private readonly playerContainer: HTMLElement;
private controlPanel: HTMLElement | null;
private currentTimeDisplay: HTMLElement | null;
private hideTimeout: number | undefined;
private playedBar: HTMLElement | null;
private progressBar: HTMLElement | null;
private totalTimeDisplay: HTMLElement | null;
private backButton: HTMLButtonElement | null;
private forwardButton: HTMLButtonElement | null;
private loopButton: HTMLButtonElement | null;
private playButton: HTMLButtonElement | null;
private muteButton: HTMLButtonElement | null;
private navControls: HTMLElement | null;
private progressControls: HTMLElement | null;
constructor({ callbacks, fullscreenTarget, getIsActive, mediaCapabilities, playerContainer, title }: TwoDControlPanelOptions) {
this.callbacks = callbacks;
this.fullscreenTarget = fullscreenTarget;
this.getIsActive = getIsActive;
this.playerContainer = playerContainer;
this.controlPanel = playerContainer.querySelector('.vrwp-panel');
const videoTitle = playerContainer.querySelector<HTMLElement>('.vrwp-video-title');
this.currentTimeDisplay = playerContainer.querySelector('.vrwp-current-time');
this.totalTimeDisplay = playerContainer.querySelector('.vrwp-total-time');
this.progressControls = playerContainer.querySelector('.vrwp-progress');
this.progressBar = playerContainer.querySelector('.vrwp-bar');
this.playedBar = playerContainer.querySelector('.vrwp-played');
this.backButton = playerContainer.querySelector('.vrwp-back');
this.forwardButton = playerContainer.querySelector('.vrwp-forward');
this.loopButton = playerContainer.querySelector('.vrwp-loop');
this.playButton = playerContainer.querySelector('.vrwp-play-toggle');
this.muteButton = playerContainer.querySelector('.vrwp-mute');
this.navControls = playerContainer.querySelector('.vrwp-nav');
if (!this.controlPanel) {
console.error('VR_WEB_PLAYER_DOM: 2D control panel was not found.');
return;
}
if (videoTitle) {
videoTitle.textContent = title;
}
this.applyCapabilities(mediaCapabilities);
this.bindControls(playerContainer, mediaCapabilities);
this.updateLoopButton(this.callbacks.getIsLooping());
}
show(): void {
if (!this.getIsActive() || !this.controlPanel) return;
this.clearHideTimeout();
this.controlPanel.classList.add('visible');
this.hideTimeout = window.setTimeout(() => this.hide(), CONTROL_PANEL_HIDE_DELAY);
}
showPersistent(): void {
if (!this.getIsActive() || !this.controlPanel) return;
this.clearHideTimeout();
this.controlPanel.classList.add('visible');
}
hide(): void {
if (!this.controlPanel) return;
this.clearHideTimeout();
this.controlPanel.classList.remove('visible');
}
position(canvas: HTMLElement): void {
if (!this.getIsActive() || !this.controlPanel) return;
const canvasRect = canvas.getBoundingClientRect();
const containerRect = this.playerContainer.getBoundingClientRect();
const bottomOffset = canvasRect.height * 0.1;
const panelHeight = this.controlPanel.offsetHeight;
const topPosition = (canvasRect.bottom - containerRect.top) - bottomOffset - panelHeight;
this.controlPanel.style.position = 'absolute';
this.controlPanel.style.top = `${topPosition}px`;
this.controlPanel.style.bottom = 'auto';
this.controlPanel.style.left = '50%';
this.controlPanel.style.transform = 'translateX(-50%)';
this.controlPanel.style.zIndex = '1000';
}
updateMuteButton(isMuted: boolean): void {
if (!this.getIsActive() || !this.muteButton) return;
if (isMuted) {
this.muteButton.classList.remove('muted');
this.muteButton.classList.add('unmuted');
setLucideIcon(this.muteButton, 'volume-x');
} else {
this.muteButton.classList.remove('unmuted');
this.muteButton.classList.add('muted');
setLucideIcon(this.muteButton, 'volume-2');
}
}
updatePlaybackButton(isPausedOrEnded: boolean): void {
if (!this.getIsActive() || !this.playButton) return;
if (isPausedOrEnded) {
this.playButton.classList.remove('playing');
this.playButton.classList.add('paused');
setLucideIcon(this.playButton, 'play');
} else {
this.playButton.classList.remove('paused');
this.playButton.classList.add('playing');
setLucideIcon(this.playButton, 'pause');
}
}
updateLoopButton(isLooping: boolean): void {
if (!this.loopButton) return;
this.loopButton.classList.toggle('active', isLooping);
this.loopButton.setAttribute('aria-pressed', String(isLooping));
this.loopButton.setAttribute('aria-label', isLooping ? 'Disable video loop' : 'Loop video');
}
updateTime(currentTime: number, duration: number): void {
if (!this.getIsActive()) return;
if (this.currentTimeDisplay) {
this.currentTimeDisplay.textContent = formatTime(currentTime);
}
if (this.totalTimeDisplay && isFinite(duration)) {
this.totalTimeDisplay.textContent = formatTime(duration);
}
if (this.playedBar && isFinite(duration) && duration > 0) {
const progress = (currentTime / duration) * 100;
this.playedBar.style.width = `${progress}%`;
}
}
private applyCapabilities(mediaCapabilities: MediaCapabilities): void {
if (!mediaCapabilities.timeline && this.progressControls) {
this.progressControls.hidden = true;
}
if (!mediaCapabilities.navigation && this.navControls) {
this.navControls.hidden = true;
}
if (!mediaCapabilities.playback && this.playButton) {
this.playButton.hidden = true;
}
if (!mediaCapabilities.playback && this.loopButton) {
this.loopButton.hidden = true;
}
if (mediaCapabilities.carousel) {
this.configureCarouselNavigation();
}
if (!mediaCapabilities.audio && this.muteButton) {
this.muteButton.hidden = true;
}
}
private bindControls(playerContainer: HTMLElement, mediaCapabilities: MediaCapabilities): void {
playerContainer.querySelector('.vrwp-fullscreen')?.addEventListener('click', () => {
this.toggleFullscreen();
});
if (mediaCapabilities.navigation) {
this.backButton?.addEventListener('click', () => {
this.callbacks.onRewind();
this.show();
});
this.forwardButton?.addEventListener('click', () => {
this.callbacks.onForward();
this.show();
});
}
if (mediaCapabilities.playback) {
this.playButton?.addEventListener('click', () => {
this.callbacks.onPlayPause();
this.show();
});
this.loopButton?.addEventListener('click', () => {
this.updateLoopButton(this.callbacks.onToggleLoop());
this.show();
});
}
if (mediaCapabilities.audio) {
this.muteButton?.addEventListener('click', () => {
this.callbacks.onMute();
this.show();
});
}
if (mediaCapabilities.timeline) {
this.progressBar?.addEventListener('click', (event) => {
const rect = this.progressBar?.getBoundingClientRect();
if (rect && rect.width > 0) {
this.callbacks.onSeek((event.clientX - rect.left) / rect.width);
}
this.show();
});
}
}
private clearHideTimeout(): void {
if (this.hideTimeout !== undefined) {
clearTimeout(this.hideTimeout);
this.hideTimeout = undefined;
}
}
private configureCarouselNavigation(): void {
if (this.backButton) {
this.backButton.setAttribute('aria-label', 'Previous image');
setLucideIcon(this.backButton, 'chevron-left');
this.backButton.querySelector('.vrwp-skip-label')?.remove();
}
if (this.forwardButton) {
this.forwardButton.setAttribute('aria-label', 'Next image');
setLucideIcon(this.forwardButton, 'chevron-right');
this.forwardButton.querySelector('.vrwp-skip-label')?.remove();
}
}
private toggleFullscreen(): void {
if (!document.fullscreenElement) {
this.fullscreenTarget.requestFullscreen().catch((err) => {
console.error('Error attempting to enable fullscreen:', err);
});
return;
}
document.exitFullscreen().catch((err) => {
console.error('Error attempting to exit fullscreen:', err);
});
}
}

View File

@@ -0,0 +1,113 @@
import type {
MediaAdapter,
MediaCapabilities,
MediaLoadCallbacks
} from './media-adapter.js';
import { getFilenameTitle } from './media-title.js';
const IMAGE_CAROUSEL_CAPABILITIES: MediaCapabilities = {
audio: false,
carousel: true,
dynamicTexture: false,
navigation: true,
playback: false,
timeline: false
};
export class ImageCarouselMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLImageElement> {
readonly capabilities = IMAGE_CAROUSEL_CAPABILITIES;
readonly kind = 'image' as const;
private currentIndex = 0;
private isHidden = false;
constructor(private readonly images: HTMLImageElement[]) {
this.images.forEach((image) => {
image.classList.add('vrwp-media', 'vrwp-image', 'vrwp-carousel-image');
});
this.applyVisibility();
}
get element(): HTMLImageElement {
return this.images[this.currentIndex];
}
get textureSource(): HTMLImageElement {
return this.element;
}
getTitle(): string {
return this.element.getAttribute('title') ||
this.element.getAttribute('alt') ||
getFilenameTitle(this.element.currentSrc || this.element.src) ||
`Image ${this.currentIndex + 1}`;
}
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
let hasReportedReady = false;
const reportReadyIfAllLoaded = () => {
if (hasReportedReady || !this.areAllImagesReady()) {
return;
}
hasReportedReady = true;
onReady();
};
this.images.forEach((image) => {
image.addEventListener('load', reportReadyIfAllLoaded);
image.addEventListener('error', onError);
});
if (this.areAllImagesReady()) {
queueMicrotask(reportReadyIfAllLoaded);
}
}
hideElement(): void {
this.isHidden = true;
this.applyVisibility();
}
load(): void {
this.images.forEach((image) => {
image.loading = 'eager';
});
}
next(): boolean {
return this.selectRelative(1);
}
previous(): boolean {
return this.selectRelative(-1);
}
shouldUpdateTexture(): boolean {
return false;
}
showElement(): void {
this.isHidden = false;
this.applyVisibility();
}
private selectRelative(offset: number): boolean {
if (this.images.length <= 1) {
return false;
}
this.currentIndex = (this.currentIndex + offset + this.images.length) % this.images.length;
this.applyVisibility();
return true;
}
private applyVisibility(): void {
this.images.forEach((image, index) => {
image.style.display = this.isHidden || index !== this.currentIndex ? 'none' : '';
});
}
private areAllImagesReady(): boolean {
return this.images.every((image) => image.complete && image.naturalWidth > 0);
}
}

View File

@@ -0,0 +1,66 @@
import type {
MediaAdapter,
MediaCapabilities,
MediaLoadCallbacks
} from './media-adapter.js';
import { getFilenameTitle } from './media-title.js';
const IMAGE_CAPABILITIES: MediaCapabilities = {
audio: false,
carousel: false,
dynamicTexture: false,
navigation: false,
playback: false,
timeline: false
};
export class ImageMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLImageElement> {
readonly capabilities = IMAGE_CAPABILITIES;
readonly kind = 'image' as const;
constructor(readonly element: HTMLImageElement) {}
get textureSource(): HTMLImageElement {
return this.element;
}
getTitle(): string {
return this.element.getAttribute('title') ||
this.element.getAttribute('alt') ||
getFilenameTitle(this.element.currentSrc || this.element.src) ||
'Image Title';
}
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
if (this.element.complete && this.element.naturalWidth > 0) {
queueMicrotask(onReady);
}
this.element.addEventListener('load', onReady);
this.element.addEventListener('error', onError);
}
hideElement(): void {
this.element.style.display = 'none';
}
load(): void {
// Images begin loading from markup. Kept for parity with video media.
}
next(): boolean {
return false;
}
previous(): boolean {
return false;
}
shouldUpdateTexture(): boolean {
return false;
}
showElement(): void {
this.element.style.display = '';
}
}

View File

@@ -0,0 +1,84 @@
import { ImageCarouselMediaAdapter } from './image-carousel-media-adapter.js';
import { ImageMediaAdapter } from './image-media-adapter.js';
import { VideoMediaAdapter } from './video-media-adapter.js';
export type MediaCapabilities = {
audio: boolean;
carousel: boolean;
dynamicTexture: boolean;
navigation: boolean;
playback: boolean;
timeline: boolean;
};
export type MediaLoadCallbacks = {
onError: (event: Event) => void;
onReady: () => void;
};
export type MediaKind = 'image' | 'video';
export interface MediaAdapter<TElement extends HTMLElement = HTMLElement, TTextureSource = TElement> {
readonly capabilities: MediaCapabilities;
readonly element: TElement;
readonly kind: MediaKind;
readonly textureSource: TTextureSource;
bindLoadState(callbacks: MediaLoadCallbacks): void;
getTitle(): string;
hideElement(): void;
load(): void;
next?(): boolean;
previous?(): boolean;
shouldUpdateTexture(): boolean;
showElement(): void;
}
export type SupportedMediaAdapter = ImageCarouselMediaAdapter | ImageMediaAdapter | VideoMediaAdapter;
export {
ImageCarouselMediaAdapter,
ImageMediaAdapter,
VideoMediaAdapter
};
export function createMediaAdapter(playerContainer: HTMLElement): SupportedMediaAdapter | null {
const mediaElements = Array.from(
playerContainer.querySelectorAll<HTMLVideoElement | HTMLImageElement>('video,img')
);
const videoElements = mediaElements.filter((element) => element.tagName.toLowerCase() === 'video');
const imageElements = mediaElements.filter((element) => element.tagName.toLowerCase() === 'img') as HTMLImageElement[];
const isCarousel = isCarouselEnabled(playerContainer);
if (isCarousel) {
if (videoElements.length > 0 || imageElements.length < 2) {
return null;
}
return new ImageCarouselMediaAdapter(imageElements);
}
if (mediaElements.length !== 1) {
return null;
}
const mediaElement = mediaElements[0];
const tagName = mediaElement.tagName.toLowerCase();
mediaElement.classList.add('vrwp-media');
if (tagName === 'video') {
mediaElement.classList.add('vrwp-video');
return new VideoMediaAdapter(mediaElement as HTMLVideoElement);
}
if (tagName === 'img') {
mediaElement.classList.add('vrwp-image');
return new ImageMediaAdapter(mediaElement as HTMLImageElement);
}
return null;
}
function isCarouselEnabled(playerContainer: HTMLElement): boolean {
const carouselValue = playerContainer.dataset?.carousel;
return carouselValue !== undefined && carouselValue.toLowerCase() !== 'false';
}

View File

@@ -0,0 +1,137 @@
type MediaControllerOptions = {
is2DModeActive: () => boolean;
on2DPlaybackResume: () => void;
playButton?: HTMLButtonElement;
video: HTMLVideoElement;
};
type HandleMediaEndedOptions = {
isIn2DMode: () => boolean;
isInVr: () => boolean;
on2DEnded: () => void;
onVrEnded: () => void;
resetToOriginalState: () => void;
};
const DEFAULT_SKIP_SECONDS = 15;
export class MediaController {
private readonly is2DModeActive: () => boolean;
private readonly on2DPlaybackResume: () => void;
private readonly playButton?: HTMLButtonElement;
private readonly video: HTMLVideoElement;
constructor({ is2DModeActive, on2DPlaybackResume, playButton, video }: MediaControllerOptions) {
this.is2DModeActive = is2DModeActive;
this.on2DPlaybackResume = on2DPlaybackResume;
this.playButton = playButton;
this.video = video;
}
enableNativeControls(): void {
this.video.controls = true;
}
forward(seconds = DEFAULT_SKIP_SECONDS): void {
if (!isFinite(this.video.duration)) return;
this.video.currentTime = Math.min(this.video.duration, this.video.currentTime + seconds);
}
handleEnded({
isIn2DMode,
isInVr,
on2DEnded,
onVrEnded,
resetToOriginalState
}: HandleMediaEndedOptions): void {
this.pauseIfPlaying();
if (isInVr()) {
onVrEnded();
return;
}
if (isIn2DMode()) {
on2DEnded();
return;
}
resetToOriginalState();
}
hidePlayButton(): void {
this.playButton?.classList.add('hidden');
}
isLooping(): boolean {
return this.video.loop;
}
pauseIfPlaying(): void {
if (!this.video.paused) {
this.video.pause();
}
}
play(): Promise<void> {
return this.video.play();
}
resetToOriginalState(): void {
this.video.pause();
this.video.currentTime = 0;
this.video.controls = false;
this.video.load();
this.playButton?.classList.remove('hidden');
if (this.playButton) {
this.playButton.disabled = false;
}
}
rewind(seconds = DEFAULT_SKIP_SECONDS): void {
this.video.currentTime = Math.max(0, this.video.currentTime - seconds);
}
seekToProgress(progress: number): void {
if (!isFinite(this.video.duration)) return;
this.video.currentTime = progress * this.video.duration;
}
toggleMute(): void {
this.video.muted = !this.video.muted;
}
toggleLoop(): boolean {
this.video.loop = !this.video.loop;
return this.video.loop;
}
togglePlayPause(): void {
if (!this.video.currentSrc) return;
if (this.video.paused || this.video.ended) {
if (this.video.ended) {
this.video.currentTime = 0;
}
if (this.video.readyState >= this.video.HAVE_ENOUGH_DATA || this.video.currentSrc) {
const playPromise = this.video.play() as Promise<void> | undefined;
if (playPromise !== undefined) {
playPromise.then(() => {
if (this.is2DModeActive() && this.video.ended === false) {
this.on2DPlaybackResume();
}
}).catch((err) => console.error('Error during video.play():', err));
} else {
console.error('video.play() did not return a promise.');
}
}
return;
}
this.video.pause();
}
}

View File

@@ -0,0 +1,3 @@
export function getFilenameTitle(source: string): string {
return source.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || '';
}

View File

@@ -0,0 +1,54 @@
type VideoEventCallbacks = {
onEnded: () => void;
onPlaybackStateChange: () => void;
onTimelineChange: () => void;
onVolumeChange: () => void;
};
type BindVideoEventsOptions = VideoEventCallbacks & {
playButton: HTMLButtonElement | undefined;
video: HTMLVideoElement;
};
export function bindVideoEvents({
onEnded,
onPlaybackStateChange,
onTimelineChange,
onVolumeChange,
playButton,
video
}: BindVideoEventsOptions): void {
video.onloadedmetadata = () => {
if (isFinite(video.duration) && playButton) {
playButton.disabled = false;
}
onTimelineChange();
onPlaybackStateChange();
onVolumeChange();
};
video.oncanplaythrough = () => {
if (playButton && video.readyState >= video.HAVE_FUTURE_DATA) {
playButton.disabled = false;
}
};
video.ontimeupdate = () => {
if (isFinite(video.duration)) {
onTimelineChange();
}
};
video.onplaying = onPlaybackStateChange;
video.onpause = onPlaybackStateChange;
video.onerror = (event) => {
const videoError = video.error;
const errorDetail = videoError ? `Code: ${videoError.code}, Message: ${videoError.message}` : 'Unknown error';
console.error('VIDEO_ERROR_EVENT:', event, 'Details:', errorDetail);
if (playButton) playButton.disabled = true;
};
video.addEventListener('ended', onEnded);
video.addEventListener('volumechange', onVolumeChange);
}

View File

@@ -0,0 +1,65 @@
import type {
MediaAdapter,
MediaCapabilities,
MediaLoadCallbacks
} from './media-adapter.js';
const VIDEO_CAPABILITIES: MediaCapabilities = {
audio: true,
carousel: false,
dynamicTexture: true,
navigation: true,
playback: true,
timeline: true
};
export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVideoElement> {
readonly capabilities = VIDEO_CAPABILITIES;
readonly kind = 'video' as const;
constructor(readonly element: HTMLVideoElement) {}
get textureSource(): HTMLVideoElement {
return this.element;
}
getTitle(): string {
return this.element.getAttribute('title') ||
this.element.querySelector('source')?.src.split('/').pop()?.split('.')[0].replace(/-/g, ' ') ||
'Video Title';
}
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
if (this.element.readyState >= this.element.HAVE_METADATA) {
queueMicrotask(onReady);
}
this.element.addEventListener('loadedmetadata', onReady);
this.element.addEventListener('canplaythrough', onReady);
this.element.addEventListener('error', onError);
}
hideElement(): void {
this.element.style.display = 'none';
}
load(): void {
this.element.load();
}
next(): boolean {
return false;
}
previous(): boolean {
return false;
}
shouldUpdateTexture(): boolean {
return !this.element.paused && !this.element.ended;
}
showElement(): void {
this.element.style.display = '';
}
}

View File

@@ -0,0 +1,168 @@
import type { ProjectionMode } from '../config.js';
type CameraControlsCallbacks = {
hideControls: () => void;
isEnabled: () => boolean;
showControls: () => void;
};
const MOUSE_SENSITIVITY = 0.002;
const TOUCH_SENSITIVITY = 0.003;
const MOMENTUM_DAMPING = 0.8;
const MAX_PITCH = Math.PI * (45 / 180);
const MAX_YAW = Math.PI * (45 / 180);
export class FallbackCameraControls {
private camera: any;
private readonly callbacks: CameraControlsCallbacks;
private cameraRotation = { yaw: 0, pitch: 0 };
private cameraVelocity = { yaw: 0, pitch: 0 };
private dragging = false;
private lastMouseX = 0;
private lastMouseY = 0;
private lastTouchX = 0;
private lastTouchY = 0;
constructor(camera: any, callbacks: CameraControlsCallbacks) {
this.camera = camera;
this.callbacks = callbacks;
}
get isDragging(): boolean {
return this.dragging;
}
reset(): void {
this.cameraRotation = { yaw: 0, pitch: 0 };
this.cameraVelocity = { yaw: 0, pitch: 0 };
this.dragging = false;
if (this.camera) {
this.camera.rotation.set(0, 0, 0);
}
}
updateCameraRotation(): void {
if (!this.camera) return;
this.cameraRotation.yaw += this.cameraVelocity.yaw;
this.cameraRotation.pitch += this.cameraVelocity.pitch;
this.cameraRotation.pitch = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, this.cameraRotation.pitch));
this.cameraRotation.yaw = Math.max(-MAX_YAW, Math.min(MAX_YAW, this.cameraRotation.yaw));
this.cameraVelocity.yaw *= MOMENTUM_DAMPING;
this.cameraVelocity.pitch *= MOMENTUM_DAMPING;
if (Math.abs(this.cameraVelocity.yaw) < 0.001) this.cameraVelocity.yaw = 0;
if (Math.abs(this.cameraVelocity.pitch) < 0.001) this.cameraVelocity.pitch = 0;
this.camera.rotation.set(this.cameraRotation.pitch, this.cameraRotation.yaw, 0);
}
addEventListeners(canvas: HTMLElement, projectionMode: ProjectionMode): void {
canvas.addEventListener('mousemove', this.onCanvasMouseMove);
if (projectionMode === 'vr180') {
canvas.addEventListener('mousedown', this.onMouseDown);
canvas.addEventListener('mousemove', this.onMouseMove);
canvas.addEventListener('mouseup', this.onMouseUp);
canvas.addEventListener('touchstart', this.onTouchStart, { passive: false });
canvas.addEventListener('touchmove', this.onTouchMove, { passive: false });
canvas.addEventListener('touchend', this.onTouchEnd, { passive: false });
} else {
canvas.addEventListener('touchstart', this.onCanvasTouchStart, { passive: true });
}
}
removeEventListeners(canvas: HTMLElement): void {
canvas.removeEventListener('mousemove', this.onCanvasMouseMove);
canvas.removeEventListener('mousedown', this.onMouseDown);
canvas.removeEventListener('mousemove', this.onMouseMove);
canvas.removeEventListener('mouseup', this.onMouseUp);
canvas.removeEventListener('touchstart', this.onTouchStart);
canvas.removeEventListener('touchmove', this.onTouchMove);
canvas.removeEventListener('touchend', this.onTouchEnd);
canvas.removeEventListener('touchstart', this.onCanvasTouchStart);
}
private readonly onCanvasMouseMove = (): void => {
if (this.callbacks.isEnabled() && !this.dragging) {
this.callbacks.showControls();
}
};
private readonly onCanvasTouchStart = (): void => {
if (this.callbacks.isEnabled()) {
this.callbacks.showControls();
}
};
private readonly onMouseDown = (event: MouseEvent): void => {
if (!this.callbacks.isEnabled()) return;
this.dragging = true;
this.lastMouseX = event.clientX;
this.lastMouseY = event.clientY;
this.cameraVelocity.yaw = 0;
this.cameraVelocity.pitch = 0;
this.callbacks.hideControls();
};
private readonly onMouseMove = (event: MouseEvent): void => {
if (!this.callbacks.isEnabled() || !this.dragging) return;
const deltaX = event.clientX - this.lastMouseX;
const deltaY = event.clientY - this.lastMouseY;
this.cameraVelocity.yaw = deltaX * MOUSE_SENSITIVITY;
this.cameraVelocity.pitch = deltaY * MOUSE_SENSITIVITY;
this.lastMouseX = event.clientX;
this.lastMouseY = event.clientY;
};
private readonly onMouseUp = (): void => {
if (!this.callbacks.isEnabled()) return;
this.dragging = false;
this.callbacks.showControls();
};
private readonly onTouchStart = (event: TouchEvent): void => {
if (!this.callbacks.isEnabled()) return;
event.preventDefault();
if (event.touches.length === 1) {
this.dragging = true;
this.lastTouchX = event.touches[0].clientX;
this.lastTouchY = event.touches[0].clientY;
this.cameraVelocity.yaw = 0;
this.cameraVelocity.pitch = 0;
this.callbacks.hideControls();
}
};
private readonly onTouchMove = (event: TouchEvent): void => {
if (!this.callbacks.isEnabled() || !this.dragging) return;
event.preventDefault();
if (event.touches.length === 1) {
const deltaX = event.touches[0].clientX - this.lastTouchX;
const deltaY = event.touches[0].clientY - this.lastTouchY;
this.cameraVelocity.yaw = deltaX * TOUCH_SENSITIVITY;
this.cameraVelocity.pitch = deltaY * TOUCH_SENSITIVITY;
this.lastTouchX = event.touches[0].clientX;
this.lastTouchY = event.touches[0].clientY;
}
};
private readonly onTouchEnd = (event: TouchEvent): void => {
if (!this.callbacks.isEnabled()) return;
event.preventDefault();
this.dragging = false;
this.callbacks.showControls();
};
}

View File

@@ -0,0 +1,283 @@
import type { ProjectionMode } from '../config.js';
import type { FallbackCameraControls } from './fallback-camera-controls.js';
import {
hideRendererCanvas,
resizeFallbackRenderer,
showFallbackCanvas
} from '../rendering/renderer-lifecycle.js';
import { TwoDControlPanel } from '../dom/two-d-control-panel.js';
import type { MediaCapabilities } from '../media/media-adapter.js';
type TwoDModeCallbacks = {
createMediaTexture: () => any;
forward: () => void;
getIsLooping: () => boolean;
positionPlaneForPresentation: (isFallback2D?: boolean) => void;
rewind: () => void;
seekToProgress: (progress: number) => void;
showActiveContentMesh: () => void;
toggleLoop: () => boolean;
toggleMute: () => void;
togglePlayPause: () => void;
};
type TwoDModeOptions = {
callbacks: TwoDModeCallbacks;
fullscreenTarget: HTMLElement;
mediaCapabilities: MediaCapabilities;
getActiveContentMesh: () => any;
getCamera: () => any;
getCameraControls: () => FallbackCameraControls | undefined;
getMaterial: () => any;
getMediaElement: () => HTMLElement | undefined;
getRenderer: () => any;
getScene: () => any;
getVideo: () => HTMLVideoElement | undefined;
playerContainer: HTMLElement;
projectionMode: ProjectionMode;
title: string;
};
const FULLSCREEN_RESIZE_DELAY = 100;
export class TwoDMode {
private readonly callbacks: TwoDModeCallbacks;
private readonly controls: TwoDControlPanel;
private readonly fullscreenTarget: HTMLElement;
private readonly mediaCapabilities: MediaCapabilities;
private readonly getActiveContentMesh: () => any;
private readonly getCamera: () => any;
private readonly getCameraControls: () => FallbackCameraControls | undefined;
private readonly getMaterial: () => any;
private readonly getMediaElement: () => HTMLElement | undefined;
private readonly getRenderer: () => any;
private readonly getScene: () => any;
private readonly getVideo: () => HTMLVideoElement | undefined;
private readonly playerContainer: HTMLElement;
private readonly projectionMode: ProjectionMode;
private active = false;
constructor(options: TwoDModeOptions) {
this.callbacks = options.callbacks;
this.fullscreenTarget = options.fullscreenTarget;
this.mediaCapabilities = options.mediaCapabilities;
this.getActiveContentMesh = options.getActiveContentMesh;
this.getCamera = options.getCamera;
this.getCameraControls = options.getCameraControls;
this.getMaterial = options.getMaterial;
this.getMediaElement = options.getMediaElement;
this.getRenderer = options.getRenderer;
this.getScene = options.getScene;
this.getVideo = options.getVideo;
this.playerContainer = options.playerContainer;
this.projectionMode = options.projectionMode;
this.controls = new TwoDControlPanel({
callbacks: {
getIsLooping: this.callbacks.getIsLooping,
onForward: () => {
this.callbacks.forward();
},
onMute: () => {
this.callbacks.toggleMute();
},
onPlayPause: this.callbacks.togglePlayPause,
onRewind: () => {
this.callbacks.rewind();
},
onSeek: (progress) => {
this.callbacks.seekToProgress(progress);
},
onToggleLoop: this.callbacks.toggleLoop
},
mediaCapabilities: this.mediaCapabilities,
fullscreenTarget: this.fullscreenTarget,
getIsActive: () => this.active,
playerContainer: this.playerContainer,
title: options.title
});
}
get isActive(): boolean {
return this.active;
}
start(): void {
const mediaElement = this.getMediaElement();
const renderer = this.getRenderer();
const camera = this.getCamera();
if (!mediaElement || !renderer || !camera) {
console.error("Required components not available for 2D mode");
return;
}
this.active = true;
this.resizeCanvasFor2D(renderer, camera);
const canvas = showFallbackCanvas(renderer);
mediaElement.style.display = 'none';
const mediaTexture = this.callbacks.createMediaTexture();
this.callbacks.positionPlaneForPresentation(this.projectionMode === 'plane');
const material = this.getMaterial();
const activeContentMesh = this.getActiveContentMesh();
if (material && activeContentMesh) {
material.map = mediaTexture;
material.needsUpdate = true;
this.callbacks.showActiveContentMesh();
}
if (this.mediaCapabilities.playback) {
this.callbacks.togglePlayPause();
}
this.addEventListeners(canvas);
this.controls.show();
this.positionControls();
this.render();
}
stop(): void {
this.active = false;
const renderer = this.getRenderer();
if (renderer?.domElement) {
this.removeEventListeners(renderer.domElement);
hideRendererCanvas(renderer);
} else {
this.removeFullscreenEventListeners();
}
this.controls.hide();
this.getCameraControls()?.reset();
this.callbacks.positionPlaneForPresentation(false);
const mediaElement = this.getMediaElement();
if (mediaElement) {
mediaElement.style.display = '';
}
}
resize(): boolean {
if (!this.active) {
return false;
}
const renderer = this.getRenderer();
const camera = this.getCamera();
if (renderer && camera) {
this.resizeCanvasFor2D(renderer, camera);
this.positionControls();
}
return true;
}
showControls(): void {
this.controls.show();
}
hideControls(): void {
this.controls.hide();
}
updateTimeline(): void {
if (!this.active) return;
if (!this.mediaCapabilities.timeline) return;
const video = this.getVideo();
if (video) {
this.controls.updateTime(video.currentTime, video.duration);
}
}
updatePlaybackButton(): void {
if (!this.active) return;
if (!this.mediaCapabilities.playback) return;
const video = this.getVideo();
if (video) {
this.controls.updatePlaybackButton(video.paused || video.ended);
}
}
updateMuteButton(): void {
if (!this.active) return;
if (!this.mediaCapabilities.audio) return;
const video = this.getVideo();
if (video) {
this.controls.updateMuteButton(video.muted);
}
}
handleVideoEnd(): void {
if (!this.active) return;
if (!this.mediaCapabilities.playback) return;
this.controls.showPersistent();
this.updatePlaybackButton();
}
private addEventListeners(canvas: HTMLElement): void {
this.getCameraControls()?.addEventListeners(canvas, this.projectionMode);
document.addEventListener('fullscreenchange', this.onFullscreenChange);
document.addEventListener('webkitfullscreenchange', this.onFullscreenChange);
document.addEventListener('mozfullscreenchange', this.onFullscreenChange);
document.addEventListener('MSFullscreenChange', this.onFullscreenChange);
}
private removeEventListeners(canvas: HTMLElement): void {
this.getCameraControls()?.removeEventListeners(canvas);
this.removeFullscreenEventListeners();
}
private removeFullscreenEventListeners(): void {
document.removeEventListener('fullscreenchange', this.onFullscreenChange);
document.removeEventListener('webkitfullscreenchange', this.onFullscreenChange);
document.removeEventListener('mozfullscreenchange', this.onFullscreenChange);
document.removeEventListener('MSFullscreenChange', this.onFullscreenChange);
}
private readonly onFullscreenChange = (): void => {
if (!this.active) return;
window.setTimeout(() => {
this.resize();
}, FULLSCREEN_RESIZE_DELAY);
};
positionControls(): void {
const renderer = this.getRenderer();
if (renderer?.domElement) {
this.controls.position(renderer.domElement);
}
}
private readonly render = (): void => {
if (!this.active) return;
const camera = this.getCamera();
if (this.projectionMode === 'vr180') {
this.getCameraControls()?.updateCameraRotation();
} else if (camera) {
camera.rotation.set(0, 0, 0);
}
const renderer = this.getRenderer();
const scene = this.getScene();
if (renderer && camera && scene) {
renderer.render(scene, camera);
}
requestAnimationFrame(this.render);
};
private resizeCanvasFor2D(renderer: any, camera: any): void {
resizeFallbackRenderer({
camera2D: camera,
playerContainer: this.playerContainer,
renderer
});
}
}

View File

@@ -0,0 +1,69 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
import {
PLANE_DISTANCE,
PLANE_HEIGHT,
PLANE_WIDTH,
type ProjectionMode
} from '../config.js';
type ContentBeforeRender = (
renderer: any,
scene: any,
activeCamera: any,
geometry: any,
material: any,
group: any
) => void;
export type ContentScene = {
activeContentMesh: any;
fallbackCamera: any;
material: any;
planeMesh: any;
vr180Mesh: any;
};
export function createContentScene(
scene: any,
projectionMode: ProjectionMode,
onBeforeRender: ContentBeforeRender
): ContentScene {
const sphereGeometry = new THREE.SphereGeometry(
500,
64,
32,
-Math.PI / 2,
Math.PI,
0,
Math.PI
);
sphereGeometry.scale(-1, 1, 1);
const material = new THREE.MeshBasicMaterial({ map: null });
const vr180Mesh = new THREE.Mesh(sphereGeometry, material);
vr180Mesh.name = 'vr180Mesh';
vr180Mesh.rotation.y = Math.PI / 2;
vr180Mesh.visible = false;
vr180Mesh.onBeforeRender = onBeforeRender;
scene.add(vr180Mesh);
const planeGeometry = new THREE.PlaneGeometry(PLANE_WIDTH, PLANE_HEIGHT);
const planeMesh = new THREE.Mesh(planeGeometry, material);
planeMesh.name = 'vrSbsPlaneMesh';
planeMesh.position.set(0, 1.6, -PLANE_DISTANCE);
planeMesh.visible = false;
planeMesh.onBeforeRender = onBeforeRender;
scene.add(planeMesh);
const fallbackCamera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
fallbackCamera.position.set(0, 1.6, 0.1);
fallbackCamera.rotation.set(0, 0, 0);
return {
activeContentMesh: projectionMode === 'plane' ? planeMesh : vr180Mesh,
fallbackCamera,
material,
planeMesh,
vr180Mesh
};
}

View File

@@ -0,0 +1,153 @@
import {
PLANE_DISTANCE,
type HeadLockMode,
type ProjectionMode
} from '../config.js';
export function isLeftEyeCamera(renderingRenderer: any, activeCamera: any): boolean {
const xrCamera = renderingRenderer.xr.getCamera();
if (xrCamera && xrCamera.cameras && xrCamera.cameras.length >= 2) {
if (activeCamera === xrCamera.cameras[0]) {
return true;
}
if (activeCamera === xrCamera.cameras[1]) {
return false;
}
const viewMatrixX = activeCamera.matrixWorldInverse.elements[12];
const leftCamX = xrCamera.cameras[0].matrixWorldInverse.elements[12];
const rightCamX = xrCamera.cameras[1].matrixWorldInverse.elements[12];
const diffToLeft = Math.abs(viewMatrixX - leftCamX);
const diffToRight = Math.abs(viewMatrixX - rightCamX);
if (diffToLeft < 0.001 || diffToLeft < diffToRight) {
return true;
}
if (diffToRight < 0.001) {
return false;
}
}
return activeCamera.projectionMatrix.elements[8] <= 0;
}
export function applySbsTextureWindow(
renderingRenderer: any,
activeCamera: any,
material: any,
is2DMode: boolean
): void {
if (!material.map) return;
const isPresentingXR = renderingRenderer.xr.isPresenting;
if (is2DMode && !isPresentingXR) {
material.map.offset.x = 0;
material.map.repeat.x = 0.5;
material.map.offset.y = 0;
material.map.repeat.y = 1;
return;
}
material.map.offset.x = 0;
material.map.repeat.x = 1;
material.map.offset.y = 0;
material.map.repeat.y = 1;
if (!isPresentingXR) {
return;
}
material.map.offset.x = isLeftEyeCamera(renderingRenderer, activeCamera) ? 0 : 0.5;
material.map.repeat.x = 0.5;
}
export function hideContentMeshes(vr180Mesh: any, planeMesh: any): void {
if (vr180Mesh) vr180Mesh.visible = false;
if (planeMesh) planeMesh.visible = false;
}
export function showActiveContentMesh(vr180Mesh: any, planeMesh: any, activeContentMesh: any): void {
hideContentMeshes(vr180Mesh, planeMesh);
if (activeContentMesh) {
activeContentMesh.visible = true;
}
}
export function positionPlaneForPresentation(
planeMesh: any,
camera2D: any,
isFallback2D: boolean,
planeDistance: number,
plane2DDistance: number
): void {
if (!planeMesh) return;
const zPosition = isFallback2D && camera2D ? camera2D.position.z - plane2DDistance : -planeDistance;
planeMesh.position.set(0, 1.6, zPosition);
}
export function shouldLockContentToHeadPosition(headLockMode: HeadLockMode, projectionMode: ProjectionMode): boolean {
if (headLockMode === 'position') {
return true;
}
if (headLockMode === 'none') {
return false;
}
return projectionMode === 'vr180';
}
export function applyHeadPositionLock(
contentMesh: any,
activeCamera: any,
projectionMode: ProjectionMode,
isHeadPositionLocked: boolean,
planeDistance = PLANE_DISTANCE
): void {
if (!contentMesh || !activeCamera || !isHeadPositionLocked) {
return;
}
const cameraPosition = getCameraWorldPosition(activeCamera);
if (!cameraPosition) {
return;
}
if (projectionMode === 'plane') {
contentMesh.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z - planeDistance);
return;
}
contentMesh.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z);
}
export function resetHeadPositionLockedContent(
vr180Mesh: any,
planeMesh: any,
planeDistance = PLANE_DISTANCE
): void {
vr180Mesh?.position?.set?.(0, 0, 0);
planeMesh?.position?.set?.(0, 1.6, -planeDistance);
}
function getCameraWorldPosition(activeCamera: any): { x: number; y: number; z: number } | null {
const matrixElements = activeCamera?.matrixWorld?.elements;
if (matrixElements && matrixElements.length >= 16) {
return {
x: matrixElements[12],
y: matrixElements[13],
z: matrixElements[14]
};
}
const position = activeCamera?.position;
if (position) {
return {
x: position.x || 0,
y: position.y || 0,
z: position.z || 0
};
}
return null;
}

View File

@@ -0,0 +1,150 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
const FALLBACK_ASPECT_RATIO = 16 / 9;
const MIN_FALLBACK_CANVAS_WIDTH = 320;
const MIN_FALLBACK_CANVAS_HEIGHT = 180;
type RendererContextHandlers = {
closeActiveXrSession: () => void;
hasActiveXrSession: () => boolean;
restoreAfterContextRestored: () => void;
};
type PlayerRenderer = {
camera: any;
renderer: any;
scene: any;
};
type ResizePlayerRendererOptions = {
camera: any;
camera2D: any;
is2DMode: boolean;
onFallbackResize: () => void;
playerContainer: HTMLElement;
renderer: any;
};
type ResizeFallbackRendererOptions = {
camera2D: any;
playerContainer: HTMLElement;
renderer: any;
};
export function createPlayerRenderer(
playerContainer: HTMLElement,
contextHandlers: RendererContextHandlers
): PlayerRenderer {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 1.6, 0.1);
scene.add(camera);
const renderer = new THREE.WebGLRenderer({ antialias: true });
if (!renderer || !renderer.isWebGLRenderer) {
throw new Error("Failed to create WebGLRenderer or it's not a valid Three.js renderer type.");
}
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true;
renderer.outputColorSpace = THREE.SRGBColorSpace;
playerContainer.appendChild(renderer.domElement);
hideRendererCanvas(renderer);
bindWebGlContextEvents(renderer, contextHandlers);
return { camera, renderer, scene };
}
export function hideRendererCanvas(renderer: any): void {
if (renderer?.domElement) {
renderer.domElement.style.display = 'none';
}
}
export function resizeFallbackRenderer({
camera2D,
playerContainer,
renderer
}: ResizeFallbackRendererOptions): void {
const { height, width } = getFallbackCanvasSize(playerContainer);
renderer.setSize(width, height);
camera2D.aspect = width / height;
camera2D.updateProjectionMatrix();
styleFallbackCanvas(renderer.domElement);
}
export function resizePlayerRenderer({
camera,
camera2D,
is2DMode,
onFallbackResize,
playerContainer,
renderer
}: ResizePlayerRendererOptions): void {
if (!renderer) return;
if (renderer.xr && renderer.xr.isPresenting) return;
if (is2DMode) {
if (!playerContainer || !camera2D) return;
resizeFallbackRenderer({ camera2D, playerContainer, renderer });
onFallbackResize();
return;
}
if (camera) {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
if (camera2D) {
camera2D.aspect = window.innerWidth / window.innerHeight;
camera2D.updateProjectionMatrix();
}
}
export function showFallbackCanvas(renderer: any): HTMLElement {
const canvas = renderer.domElement;
canvas.style.position = 'relative';
styleFallbackCanvas(canvas);
canvas.style.display = '';
return canvas;
}
function bindWebGlContextEvents(renderer: any, handlers: RendererContextHandlers): void {
const gl = renderer.getContext();
if (!gl) {
throw new Error('Failed to get WebGL context from renderer.');
}
gl.canvas.addEventListener('webglcontextlost', (event: Event) => {
event.preventDefault();
console.error('CONTEXT_EVENT: WebGL Context Lost! xrSession active?', handlers.hasActiveXrSession(), event);
if (handlers.hasActiveXrSession()) {
handlers.closeActiveXrSession();
}
}, false);
gl.canvas.addEventListener('webglcontextrestored', () => {
console.log('CONTEXT_EVENT: WebGL Context Restored.');
handlers.restoreAfterContextRestored();
}, false);
}
function getFallbackCanvasSize(playerContainer: HTMLElement): { height: number; width: number } {
const containerRect = playerContainer.getBoundingClientRect();
const containerWidth = containerRect.width;
const calculatedHeight = containerWidth / FALLBACK_ASPECT_RATIO;
return {
height: Math.max(calculatedHeight, MIN_FALLBACK_CANVAS_HEIGHT),
width: Math.max(containerWidth, MIN_FALLBACK_CANVAS_WIDTH)
};
}
function styleFallbackCanvas(canvas: HTMLElement): void {
canvas.style.width = '100%';
canvas.style.height = 'auto';
canvas.style.aspectRatio = '16/9';
}

View File

@@ -0,0 +1,73 @@
export type ManagedTexture = {
needsUpdate?: boolean;
dispose(): void;
};
export type ManagedMaterial<TTexture extends ManagedTexture = ManagedTexture> = {
map: TTexture | null;
needsUpdate: boolean;
};
export type TextureFactory<TSource, TTexture extends ManagedTexture = ManagedTexture> = (source: TSource) => TTexture;
export class MediaTextureManager<TSource, TTexture extends ManagedTexture = ManagedTexture> {
private texture: TTexture | null = null;
private readonly createTexture: TextureFactory<TSource, TTexture>;
private readonly shouldUpdateTexture: () => boolean;
private source: TSource;
constructor(source: TSource, createTexture: TextureFactory<TSource, TTexture>, shouldUpdateTexture: () => boolean) {
this.createTexture = createTexture;
this.shouldUpdateTexture = shouldUpdateTexture;
this.source = source;
}
get current(): TTexture | null {
return this.texture;
}
setSource(source: TSource): void {
this.source = source;
}
assignToMaterial(material: ManagedMaterial<TTexture>): TTexture {
const texture = this.create();
material.map = texture;
material.needsUpdate = true;
return texture;
}
clearMaterial(material?: ManagedMaterial<TTexture> | null): void {
if (material?.map) {
const materialTexture = material.map;
material.map = null;
material.needsUpdate = true;
materialTexture.dispose();
if (materialTexture === this.texture) {
this.texture = null;
}
}
this.dispose();
}
create(): TTexture {
this.dispose();
this.texture = this.createTexture(this.source);
return this.texture;
}
dispose(): void {
if (this.texture) {
this.texture.dispose();
this.texture = null;
}
}
updateIfNeeded(): void {
if (this.texture && this.shouldUpdateTexture()) {
this.texture.needsUpdate = true;
}
}
}

View File

@@ -0,0 +1,117 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
import { drawLucideIcon, type LucideIconName } from '../dom/icons.js';
type Radius = number | {
tl?: number;
tr?: number;
br?: number;
bl?: number;
};
export function drawRoundedRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: Radius = 5,
fill: boolean | string,
stroke: boolean | string = true
): void {
let corners;
if (typeof radius === 'number') {
corners = { tl: radius, tr: radius, br: radius, bl: radius };
} else {
corners = { tl: 0, tr: 0, br: 0, bl: 0, ...radius };
}
if (width < 2 * corners.tl) corners.tl = width / 2;
if (width < 2 * corners.tr) corners.tr = width / 2;
if (width < 2 * corners.bl) corners.bl = width / 2;
if (width < 2 * corners.br) corners.br = width / 2;
if (height < 2 * corners.tl) corners.tl = height / 2;
if (height < 2 * corners.tr) corners.tr = height / 2;
if (height < 2 * corners.bl) corners.bl = height / 2;
if (height < 2 * corners.br) corners.br = height / 2;
ctx.beginPath();
ctx.moveTo(x + corners.tl, y);
ctx.lineTo(x + width - corners.tr, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + corners.tr);
ctx.lineTo(x + width, y + height - corners.br);
ctx.quadraticCurveTo(x + width, y + height, x + width - corners.br, y + height);
ctx.lineTo(x + corners.bl, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - corners.bl);
ctx.lineTo(x, y + corners.tl);
ctx.quadraticCurveTo(x, y, x + corners.tl, y);
ctx.closePath();
if (fill) {
if (typeof fill === 'string') ctx.fillStyle = fill;
ctx.fill();
}
if (stroke) {
if (typeof stroke === 'string') ctx.strokeStyle = stroke;
ctx.stroke();
}
}
export function createLucideButtonTexture(
iconName: LucideIconName,
color = '#ffffff',
textureSize = 128,
iconSize = 82,
skipLabel?: string
) {
const canvas = document.createElement('canvas');
canvas.width = textureSize;
canvas.height = textureSize;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Unable to create 2D canvas context for Lucide button texture.');
}
const iconOffset = (textureSize - iconSize) / 2;
drawLucideIcon(ctx, iconName, iconOffset, iconOffset, iconSize, color, 2);
if (skipLabel) {
ctx.fillStyle = color;
ctx.font = `700 ${Math.round(textureSize * 0.18)}px Helvetica, Arial, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(skipLabel, textureSize / 2, textureSize / 2);
}
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
texture.needsUpdate = true;
return texture;
}
export function createVideoTexture(video: HTMLVideoElement) {
const texture = new THREE.VideoTexture(video);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
export function createImageTexture(image: HTMLImageElement) {
const texture = new THREE.Texture(image);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
}
export function createMediaTexture(source: HTMLImageElement | HTMLVideoElement) {
if (source.tagName.toLowerCase() === 'img') {
return createImageTexture(source as HTMLImageElement);
}
return createVideoTexture(source as HTMLVideoElement);
}

View File

@@ -0,0 +1,207 @@
.vrwp {
position: relative;
display: inline-block;
width: 100%;
}
.vrwp [hidden] {
display: none !important;
}
.vrwp-media,
.vrwp canvas {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
display: block;
object-fit: contain;
}
.vrwp-play-button {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: none;
border: none;
cursor: pointer;
padding: 0;
width: 80px;
height: 80px;
transition: opacity 0.3s ease, transform 0.2s ease;
z-index: 10;
}
.vrwp-play-button:hover {
transform: translate(-50%, -50%) scale(1.1);
}
.vrwp-play-button:active {
transform: translate(-50%, -50%) scale(0.95);
}
.vrwp-play-button.hidden {
opacity: 0;
pointer-events: none;
}
.vrwp-play-button .vrwp-icon {
width: 100%;
height: 100%;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.45));
}
@media (max-width: 600px) {
.vrwp-play-button {
width: 60px;
height: 60px;
}
}
@media (min-width: 900px) {
.vrwp-play-button {
width: 100px;
height: 100px;
}
}
.vrwp-panel {
position: absolute;
bottom: 10%;
left: 50%;
transform: translateX(-50%);
width: 80%;
padding: 20px;
border-radius: 30px;
background: rgba(0, 0, 0, 0.70);
color: #fff;
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
z-index: 100;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0.15s ease;
pointer-events: none;
}
.vrwp-panel.visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.vrwp-status {
margin: 0 12px 12px 12px;
}
.vrwp-video-title {
text-align: center;
margin: 0 0 16px 0;
font-size: 1rem;
font-weight: 500;
}
.vrwp-current-time,
.vrwp-total-time {
margin: 0;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
}
.vrwp-progress {
display: grid;
grid-template-columns: min-content 1fr min-content;
grid-gap: 8px;
align-items: center;
}
.vrwp-bar {
width: 100%;
height: 4px;
border-radius: 2px;
background: #666;
cursor: pointer;
position: relative;
}
.vrwp-played {
border-radius: 2px;
background: #fff;
height: 4px;
width: 0%;
transition: width 0.1s ease;
}
.vrwp-controls {
display: grid;
grid-template-areas: "full lflex nav rflex loop mute";
grid-template-columns: 44px 1fr 156px 1fr 44px 44px;
column-gap: 8px;
height: 44px;
}
.vrwp-panel button {
cursor: pointer;
border: none;
background-color: transparent;
color: #fff;
display: grid;
place-items: center;
padding: 0;
position: relative;
transition: color 0.15s ease-in-out;
}
.vrwp-panel button:hover {
color: #d8d8d8;
}
.vrwp-icon {
width: 28px;
height: 28px;
stroke: currentColor;
}
.vrwp-fullscreen,
.vrwp-loop,
.vrwp-mute,
.vrwp-back,
.vrwp-play-toggle,
.vrwp-forward {
width: 44px;
height: 44px;
}
.vrwp-fullscreen {
grid-area: full;
}
.vrwp-mute {
grid-area: mute;
}
.vrwp-loop {
grid-area: loop;
}
.vrwp-loop.active {
color: #7dd3fc;
}
.vrwp-nav {
grid-area: nav;
display: grid;
grid-template-columns: 44px 44px 44px;
grid-gap: 12px;
height: 44px;
}
.vrwp-skip-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -48%);
font-size: 0.625rem;
font-weight: 700;
line-height: 1;
pointer-events: none;
}

28
src/vr180player/types.d.ts vendored Normal file
View File

@@ -0,0 +1,28 @@
declare module 'https://unpkg.com/three/build/three.module.js' {
export const Matrix4: any;
export const CanvasTexture: any;
export const Texture: any;
export const VideoTexture: any;
export const LinearFilter: any;
export const SRGBColorSpace: any;
export const Scene: any;
export const PerspectiveCamera: any;
export const WebGLRenderer: any;
export const SphereGeometry: any;
export const MeshBasicMaterial: any;
export const Mesh: any;
export const PlaneGeometry: any;
export const Group: any;
export const Raycaster: any;
export const LineBasicMaterial: any;
export const BufferGeometry: any;
export const Vector3: any;
export const Line: any;
}
interface Navigator {
xr?: {
isSessionSupported(mode: string): Promise<boolean>;
requestSession(mode: string, options?: unknown): Promise<any>;
};
}

View File

@@ -0,0 +1,13 @@
export function formatTime(seconds: number): string {
if (!isFinite(seconds)) return '00:00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}

View File

@@ -0,0 +1,685 @@
import {
PLANE_2D_DISTANCE,
PLANE_DISTANCE,
type HeadLockMode,
type ProjectionMode
} from './config.js';
import { bootstrapPlayer, type BootstrapContext } from './bootstrap.js';
import { createContentScene } from './rendering/content-scene.js';
import {
applyHeadPositionLock as applyHeadPositionLockCore,
applySbsTextureWindow as applySbsTextureWindowCore,
hideContentMeshes as hideContentMeshesCore,
positionPlaneForPresentation as positionPlaneForPresentationCore,
resetHeadPositionLockedContent as resetHeadPositionLockedContentCore,
shouldLockContentToHeadPosition,
showActiveContentMesh as showActiveContentMeshCore
} from './rendering/projection.js';
import { createMediaTexture as createMediaTextureCore } from './rendering/three-utils.js';
import { FallbackCameraControls } from './modes/fallback-camera-controls.js';
import { MediaController } from './media/media-controller.js';
import { createVrInputRig } from './xr/input-rig.js';
import { handleVrControllerSelect } from './xr/vr-controller-interactions.js';
import { bindVideoEvents } from './media/video-events.js';
import {
createVrControlPanel,
type VrControlPanel,
updateVrLoopButtonIcon,
updateVrPlayPauseButtonIcon,
updateVrSeekBarAppearance,
updateVrVolumeButtonIcon
} from './xr/vr-control-panel.js';
import { VrPanelVisibility } from './xr/vr-panel-visibility.js';
import { TwoDMode } from './modes/two-d-mode.js';
import {
createPlayerRenderer,
resizePlayerRenderer
} from './rendering/renderer-lifecycle.js';
import { MediaTextureManager } from './rendering/texture-manager.js';
import type { SupportedMediaAdapter } from './media/media-adapter.js';
export class PlayerSession {
private readonly headLockMode: HeadLockMode;
private readonly mediaAdapter: SupportedMediaAdapter;
private readonly playBtn: HTMLButtonElement;
private readonly playerContainer: HTMLElement;
private readonly projectionMode: ProjectionMode;
private readonly uiElements: any[] = [];
private readonly vrPanelVisibility = new VrPanelVisibility();
private readonly handleVrSessionEnd = (event: any) => this.onVRSessionEnd(event);
private readonly renderXrFrame = (timestamp: number, frame: any) => this.renderXR(timestamp, frame);
private activeContentMesh: any;
private camera: any;
private camera2D: any;
private fallbackCameraControls: FallbackCameraControls | undefined;
private frameCounter = 0;
private isXrLoopActive = false;
private mediaController: MediaController | undefined;
private planeMesh: any;
private raycaster: any;
private renderer: any;
private scene: any;
private sphereMaterial: any;
private textureManager: MediaTextureManager<HTMLImageElement | HTMLVideoElement> | undefined;
private twoDMode: TwoDMode | undefined;
private video: HTMLVideoElement | undefined;
private vr180Mesh: any;
private vrControlPanel: any;
private vrPanel: VrControlPanel | undefined;
private xrInputRig: any;
private xrSession: any = null;
constructor(context: BootstrapContext) {
this.playerContainer = context.playerContainer;
this.projectionMode = context.projectionMode;
this.headLockMode = context.headLockMode;
this.mediaAdapter = context.mediaAdapter;
this.video = context.mediaAdapter.kind === 'video' ? context.mediaAdapter.element : undefined;
this.playBtn = context.playButton;
}
init(): void {
try {
const playerRenderer = createPlayerRenderer(this.playerContainer, {
closeActiveXrSession: () => this.closeActiveXrSessionAfterContextLoss(),
hasActiveXrSession: () => !!this.xrSession,
restoreAfterContextRestored: () => this.restoreVideoTextureAfterContextRestored()
});
this.scene = playerRenderer.scene;
this.camera = playerRenderer.camera;
this.renderer = playerRenderer.renderer;
this.video = this.mediaAdapter.kind === 'video' ? this.mediaAdapter.element : undefined;
this.textureManager = new MediaTextureManager(
this.mediaAdapter.textureSource,
createMediaTextureCore,
() => this.mediaAdapter.shouldUpdateTexture()
);
this.mediaController = this.video
? new MediaController({
is2DModeActive: () => this.is2DModeActive(),
on2DPlaybackResume: () => this.show2DControlPanel(),
playButton: this.playBtn,
video: this.video
})
: undefined;
const contentScene = createContentScene(this.scene, this.projectionMode, (renderer, scene, activeCamera, geometry, material, group) => {
this.applySbsTextureWindow(renderer, activeCamera, material);
});
this.sphereMaterial = contentScene.material;
this.vr180Mesh = contentScene.vr180Mesh;
this.planeMesh = contentScene.planeMesh;
this.activeContentMesh = contentScene.activeContentMesh;
this.uiElements.push(this.activeContentMesh);
this.camera2D = contentScene.fallbackCamera;
this.fallbackCameraControls = new FallbackCameraControls(this.camera2D, {
hideControls: () => this.hide2DControlPanel(),
isEnabled: () => this.is2DModeActive(),
showControls: () => this.show2DControlPanel()
});
this.twoDMode = new TwoDMode({
callbacks: {
createMediaTexture: () => this.createMediaTexture(),
forward: () => this.navigateForward(),
getIsLooping: () => this.mediaController?.isLooping() ?? false,
positionPlaneForPresentation: (isFallback2D) => this.positionPlaneForPresentation(isFallback2D),
rewind: () => this.navigateBackward(),
seekToProgress: (progress) => this.mediaController?.seekToProgress(progress),
showActiveContentMesh: () => this.showActiveContentMesh(),
toggleLoop: () => this.toggleLoop(),
toggleMute: () => this.mediaController?.toggleMute(),
togglePlayPause: () => this.mediaController?.togglePlayPause()
},
fullscreenTarget: this.playerContainer,
mediaCapabilities: this.mediaAdapter.capabilities,
getActiveContentMesh: () => this.activeContentMesh,
getCamera: () => this.camera2D,
getCameraControls: () => this.fallbackCameraControls,
getMaterial: () => this.sphereMaterial,
getMediaElement: () => this.mediaAdapter.element,
getRenderer: () => this.renderer,
getScene: () => this.scene,
getVideo: () => this.video,
playerContainer: this.playerContainer,
projectionMode: this.projectionMode,
title: this.getMediaTitle()
});
} catch (e) {
console.error('INIT_ERROR (Phase 1 - Core Setup):', e);
this.renderer = null;
return;
}
try {
this.vrPanel = createVrControlPanel(this.scene, this.getMediaTitle(), this.mediaAdapter.capabilities);
this.vrControlPanel = this.vrPanel.group;
this.vrPanelVisibility.setPanel(this.vrPanel);
this.uiElements.push(...this.vrPanel.interactables);
this.xrInputRig = createVrInputRig(this.scene, this.renderer, (event) => this.onSelectStartVR(event));
this.raycaster = this.xrInputRig.raycaster;
} catch (e) {
console.error('INIT_ERROR (Phase 2 - VR Controls Setup):', e);
}
try {
this.playBtn.addEventListener('click', () => {
void this.handleEnterVRButtonClick();
});
window.addEventListener('resize', () => this.onWindowResize());
if (this.video) {
bindVideoEvents({
onEnded: () => this.onVideoEnded(),
onPlaybackStateChange: () => {
this.updateVRPlayPauseButtonIcon();
this.update2DPlayPauseButton();
},
onTimelineChange: () => {
this.updateSeekBarAppearance();
this.update2DControlPanel();
},
onVolumeChange: () => {
this.updateVRVolumeButtonIcon();
this.update2DMuteButton();
},
playButton: this.playBtn,
video: this.video
});
}
} catch (e) {
console.error('INIT_ERROR (Phase 3 - Event Listeners):', e);
}
}
private applySbsTextureWindow(renderingRenderer: any, activeCamera: any, material: any): void {
applySbsTextureWindowCore(renderingRenderer, activeCamera, material, this.is2DModeActive());
}
private hideContentMeshes(): void {
hideContentMeshesCore(this.vr180Mesh, this.planeMesh);
}
private showActiveContentMesh(): void {
showActiveContentMeshCore(this.vr180Mesh, this.planeMesh, this.activeContentMesh);
}
private positionPlaneForPresentation(isFallback2D = false): void {
positionPlaneForPresentationCore(this.planeMesh, this.camera2D, isFallback2D, PLANE_DISTANCE, PLANE_2D_DISTANCE);
}
private updateHeadPositionLock(): void {
if (!this.renderer?.xr?.isPresenting || !this.activeContentMesh) {
return;
}
const xrCamera = this.renderer.xr.getCamera?.(this.camera) || this.camera;
applyHeadPositionLockCore(
this.activeContentMesh,
xrCamera,
this.projectionMode,
shouldLockContentToHeadPosition(this.headLockMode, this.projectionMode),
PLANE_DISTANCE
);
}
private resetHeadPositionLock(): void {
resetHeadPositionLockedContentCore(this.vr180Mesh, this.planeMesh, PLANE_DISTANCE);
}
private createMediaTexture(): any {
if (!this.textureManager) {
throw new Error('Media texture manager is not initialized.');
}
return this.textureManager.create();
}
private refreshMediaTexture(): void {
if (!this.textureManager || !this.sphereMaterial) {
return;
}
this.textureManager.setSource(this.mediaAdapter.textureSource);
this.textureManager.assignToMaterial(this.sphereMaterial);
if (this.renderer?.xr?.isPresenting || this.twoDMode?.isActive) {
this.mediaAdapter.hideElement();
}
}
private navigateForward(): void {
if (this.mediaAdapter.next?.()) {
this.refreshMediaTexture();
return;
}
this.mediaController?.forward();
this.updateSeekBarAppearance();
}
private navigateBackward(): void {
if (this.mediaAdapter.previous?.()) {
this.refreshMediaTexture();
return;
}
this.mediaController?.rewind();
this.updateSeekBarAppearance();
}
private is2DModeActive(): boolean {
return this.twoDMode?.isActive ?? false;
}
private closeActiveXrSessionAfterContextLoss(): void {
if (!this.xrSession) return;
const sessionToClose = this.xrSession;
this.xrSession = null;
sessionToClose.removeEventListener('end', this.handleVrSessionEnd);
sessionToClose.end().catch((e) => {
console.error('Error ending session on context lost:', e);
}).finally(() => {
this.onVRSessionEnd({ session: sessionToClose });
});
}
private restoreVideoTextureAfterContextRestored(): void {
if (this.sphereMaterial && this.activeContentMesh && this.activeContentMesh.visible && this.renderer.xr.isPresenting && this.xrSession) {
this.textureManager?.assignToMaterial(this.sphereMaterial);
this.updateVRPlayPauseButtonIcon();
this.updateVRVolumeButtonIcon();
console.log('Re-initialized media texture after context restoration during VR.');
}
}
private getMediaTitle(): string {
return this.mediaAdapter.getTitle() || 'Media Title';
}
private updateVRPlayPauseButtonIcon(): void {
if (!this.video) {
return;
}
updateVrPlayPauseButtonIcon(this.vrPanel, this.video.paused || this.video.ended);
}
private updateVRLoopButtonIcon(): void {
updateVrLoopButtonIcon(this.vrPanel, this.mediaController?.isLooping() ?? false);
}
private updateVRVolumeButtonIcon(): void {
if (!this.video) {
return;
}
updateVrVolumeButtonIcon(this.vrPanel, this.video.muted || this.video.volume === 0);
}
private updateSeekBarAppearance(): void {
const progress = this.video && isFinite(this.video.duration) && this.video.duration > 0
? this.video.currentTime / this.video.duration
: null;
updateVrSeekBarAppearance(this.vrPanel, progress);
}
private animatePanelFade(timestamp: number): void {
this.vrPanelVisibility.updateFade(timestamp);
}
private showPanel(): void {
this.vrPanelVisibility.show();
}
private showPanelPersistent(): void {
this.vrPanelVisibility.showPersistent();
}
private hidePanel(): void {
this.vrPanelVisibility.hide();
}
private getVisibleVrPanelInteractables(): any[] {
return this.vrPanelVisibility.isVisible ? (this.vrPanel?.interactables ?? []) : [];
}
private onWindowResize(): void {
if (!this.renderer) return;
if (this.twoDMode?.resize()) return;
resizePlayerRenderer({
camera: this.camera,
camera2D: this.camera2D,
is2DMode: false,
onFallbackResize: () => {},
playerContainer: this.playerContainer,
renderer: this.renderer
});
}
private show2DControlPanel(): void {
this.twoDMode?.showControls();
}
private hide2DControlPanel(): void {
this.twoDMode?.hideControls();
}
private update2DControlPanel(): void {
this.twoDMode?.updateTimeline();
}
private update2DPlayPauseButton(): void {
this.twoDMode?.updatePlaybackButton();
}
private update2DMuteButton(): void {
this.twoDMode?.updateMuteButton();
}
private toggleLoop(): boolean {
const isLooping = this.mediaController?.toggleLoop() ?? false;
this.updateVRLoopButtonIcon();
return isLooping;
}
private handle2DVideoEnd(): void {
this.twoDMode?.handleVideoEnd();
}
private handleVrVideoEnd(): void {
this.updateVRPlayPauseButtonIcon();
this.updateSeekBarAppearance();
this.showPanelPersistent();
}
private resetToOriginalState(): void {
if (this.mediaController) {
this.mediaController.resetToOriginalState();
} else {
this.playBtn.classList.remove('hidden');
this.playBtn.disabled = false;
}
if (this.twoDMode?.isActive) {
this.twoDMode.stop();
this.onWindowResize();
}
}
private onVideoEnded(): void {
if (!this.mediaController) {
this.resetToOriginalState();
return;
}
this.mediaController.handleEnded({
isIn2DMode: () => this.is2DModeActive(),
isInVr: () => Boolean(this.xrSession && this.renderer && this.renderer.xr.isPresenting),
on2DEnded: () => this.handle2DVideoEnd(),
onVrEnded: () => this.handleVrVideoEnd(),
resetToOriginalState: () => this.resetToOriginalState()
});
}
private onSelectStartVR(event: any): void {
handleVrControllerSelect(event, {
beginSeekDrag: (controller) => {
this.xrInputRig?.beginSeekDrag(controller, this.vrPanel, (progress) => {
this.mediaController?.seekToProgress(progress);
this.updateSeekBarAppearance();
});
},
exitVr: () => {
if (this.xrSession) void this.actualSessionToggle();
},
forward: () => {
this.navigateForward();
},
hidePanel: () => this.hidePanel(),
isPanelVisible: () => this.vrPanelVisibility.isVisible,
raycaster: this.raycaster,
rewind: () => {
this.navigateBackward();
},
seek: (progress) => {
this.mediaController?.seekToProgress(progress);
this.updateSeekBarAppearance();
},
showPanel: () => this.showPanel(),
toggleMute: () => {
this.mediaController?.toggleMute();
},
toggleLoop: () => this.toggleLoop(),
togglePlayPause: () => {
this.mediaController?.togglePlayPause();
},
uiElements: this.uiElements,
vrPanel: this.vrPanel
});
}
private async handleEnterVRButtonClick(): Promise<void> {
if (!this.mediaAdapter) {
console.error('Media element not found for VR button click.');
return;
}
this.hideEnterButton();
if (this.playBtn.dataset.xrSupported === 'true') {
await this.actualSessionToggle();
return;
}
this.resetHeadPositionLock();
this.twoDMode?.start();
}
private async actualSessionToggle(): Promise<void> {
if (!this.renderer || !this.renderer.isWebGLRenderer) {
console.error('CRITICAL_ERROR: actualSessionToggle: renderer is NOT a WebGLRenderer or is null!', this.renderer);
return;
}
if (this.xrSession) {
const sessionToClose = this.xrSession;
this.xrSession = null;
if (this.vrControlPanel) {
this.vrPanelVisibility.hideImmediately();
}
sessionToClose.end().catch((err) => {
console.error('Error calling .end() on session:', err);
this.onVRSessionEnd({ session: sessionToClose });
});
return;
}
try {
const session = await navigator.xr.requestSession('immersive-vr', {
requiredFeatures: ['local-floor'],
optionalFeatures: ['hand-tracking']
});
if (!session) { throw new Error('requestSession returned no session.'); }
this.xrSession = session;
this.xrSession.addEventListener('end', this.handleVrSessionEnd);
this.mediaAdapter.hideElement();
if (this.mediaController && this.video && (this.video.paused || this.video.ended)) {
try {
await this.mediaController.play();
} catch (playError) {
console.error('Failed to play video after obtaining XR session:', playError);
}
}
if (this.camera) this.camera.updateProjectionMatrix();
this.positionPlaneForPresentation(false);
this.textureManager?.dispose();
if (!this.activeContentMesh || !this.sphereMaterial) {
throw new Error('VR mesh components not ready for texture.');
}
if (!this.textureManager) {
throw new Error('Media texture manager is not initialized.');
}
this.textureManager.assignToMaterial(this.sphereMaterial);
this.showActiveContentMesh();
this.updateVRPlayPauseButtonIcon();
this.updateVRLoopButtonIcon();
this.updateVRVolumeButtonIcon();
if (this.vrControlPanel) {
this.vrPanelVisibility.hideImmediately();
}
await this.renderer.xr.setSession(this.xrSession);
this.xrInputRig?.showOverlays();
this.isXrLoopActive = true;
this.renderer.setAnimationLoop(this.renderXrFrame);
this.frameCounter = 0;
} catch (err) {
const sessionStartError = 'XR_ERROR: Failed to start VR session: ' + (err.message || String(err));
console.error(sessionStartError, err);
this.isXrLoopActive = false;
this.hideContentMeshes();
this.textureManager?.clearMaterial(this.sphereMaterial);
if (this.vrControlPanel) {
this.vrPanelVisibility.hideImmediately();
}
if (this.xrSession) {
this.xrSession.removeEventListener('end', this.handleVrSessionEnd);
const tempSession = this.xrSession;
this.xrSession = null;
tempSession.end().catch(() => {}).finally(() => {
this.onVRSessionEnd({ session: tempSession });
});
} else {
this.onVRSessionEnd({ session: null });
}
if (this.renderer && this.renderer.getAnimationLoop && this.renderer.getAnimationLoop()) {
this.renderer.setAnimationLoop(null);
}
}
}
private hideEnterButton(): void {
if (this.mediaController) {
this.mediaController.hidePlayButton();
return;
}
this.playBtn.classList.add('hidden');
}
private onVRSessionEnd(event: any): void {
const endedSession = event.session;
this.isXrLoopActive = false;
if (this.renderer) {
if (this.renderer.getAnimationLoop && this.renderer.getAnimationLoop()) {
this.renderer.setAnimationLoop(null);
}
}
this.mediaAdapter.showElement();
this.mediaController?.pauseIfPlaying();
this.textureManager?.clearMaterial(this.sphereMaterial);
this.hideContentMeshes();
this.resetHeadPositionLock();
if (this.vrControlPanel) {
this.vrPanelVisibility.hideImmediately();
}
this.xrInputRig?.hideOverlays();
if (endedSession && typeof endedSession.removeEventListener === 'function') {
endedSession.removeEventListener('end', this.handleVrSessionEnd);
}
if (this.xrSession === endedSession || this.xrSession === null) {
this.xrSession = null;
} else if (this.xrSession && endedSession) {
console.warn('onVRSessionEnd: Global xrSession was different from the endedSession. Global xrSession:', this.xrSession, 'Ended session:', endedSession);
this.xrSession = null;
}
this.resetToOriginalState();
this.onWindowResize();
}
private renderXR(timestamp: number, frame: any): void {
if (!this.isXrLoopActive) {
return;
}
this.frameCounter++;
if (!this.renderer || !this.renderer.xr || !this.renderer.xr.isPresenting) {
console.warn('renderXR called but not in a valid XR presenting state. Stopping loop.');
this.isXrLoopActive = false;
if (this.renderer && this.renderer.getAnimationLoop && this.renderer.getAnimationLoop()) {
this.renderer.setAnimationLoop(null);
}
return;
}
if (this.vrPanelVisibility.isFading) {
this.animatePanelFade(timestamp);
}
this.xrInputRig?.update(timestamp, this.getVisibleVrPanelInteractables());
if (!frame) {
console.warn('renderXR called without an XRFrame. Skipping render.');
return;
}
if (this.frameCounter > 0 && this.frameCounter % 3600 === 0) {
const gl = this.renderer.getContext();
const error = gl.getError();
if (error !== gl.NO_ERROR) {
console.error(`WEBGL_ERROR_IN_RENDER_LOOP (F${this.frameCounter}):`, error, gl.enumToString ? gl.enumToString(error) : error);
}
}
try {
this.updateHeadPositionLock();
this.textureManager?.updateIfNeeded();
this.renderer.render(this.scene, this.camera);
} catch (error) {
const renderErrorMsg = 'ERROR_IN_RENDERXR_LOOP (F' + this.frameCounter + '): ' + (error.message || String(error));
console.error(renderErrorMsg, error);
console.error('Render loop error. Attempting to exit VR.');
this.isXrLoopActive = false;
const sessionToCloseOnError = this.xrSession;
this.xrSession = null;
if (sessionToCloseOnError) {
sessionToCloseOnError.removeEventListener('end', this.handleVrSessionEnd);
sessionToCloseOnError.end().catch((e) => {
console.error('Error trying to end session after render loop crash:', e);
}).finally(() => {
this.onVRSessionEnd({ session: sessionToCloseOnError });
});
} else {
this.onVRSessionEnd({ session: null });
}
}
}
}
const playerBase = new URL('.', import.meta.url).href;
let activeSession: PlayerSession | undefined;
bootstrapPlayer(playerBase, (context) => {
activeSession = new PlayerSession(context);
activeSession.init();
});

View File

@@ -0,0 +1,90 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
import {
computePalmAimRay,
type PalmAimRay,
type VectorLike
} from './hand-aim.js';
export type AimRay = {
direction: any;
origin: any;
};
export const DEFAULT_RAY_DIRECTION = new THREE.Vector3(0, 0, -1);
export function getHandAimRay(hand: any): AimRay | null {
const joints = hand?.joints;
if (!joints) {
return null;
}
const palmAimRay = computePalmAimRay({
handedness: getHandedness(hand),
indexMetacarpal: getJointWorldPosition(joints['index-finger-metacarpal']),
middleMetacarpal: getJointWorldPosition(joints['middle-finger-metacarpal']),
pinkyMetacarpal: getJointWorldPosition(joints['pinky-finger-metacarpal']),
ringMetacarpal: getJointWorldPosition(joints['ring-finger-metacarpal']),
wrist: getJointWorldPosition(joints.wrist)
});
if (!palmAimRay) {
return null;
}
return toAimRay(palmAimRay);
}
export function toPalmAimRay(ray: AimRay): PalmAimRay {
return {
direction: fromThreeVector(ray.direction),
origin: fromThreeVector(ray.origin)
};
}
export function toAimRay(ray: PalmAimRay): AimRay {
return {
direction: toThreeVector(ray.direction),
origin: toThreeVector(ray.origin)
};
}
export function rememberHandedness(hand: any, event: any): void {
const handedness = event?.data?.handedness ||
event?.data?.inputSource?.handedness ||
hand?.inputState?.handedness;
if (handedness !== 'left' && handedness !== 'right') {
return;
}
hand.userData = {
...hand.userData,
vrwpHandedness: handedness
};
}
export function getHandedness(hand: any): string | undefined {
return hand?.userData?.vrwpHandedness ||
hand?.inputState?.handedness ||
hand?.userData?.inputSource?.handedness;
}
export function getJointWorldPosition(joint: any): VectorLike | null {
if (!joint?.getWorldPosition) {
return null;
}
joint.updateMatrixWorld?.(true);
return joint.getWorldPosition(new THREE.Vector3());
}
export function toThreeVector(vector: VectorLike): any {
return new THREE.Vector3(vector.x, vector.y, vector.z);
}
export function fromThreeVector(vector: any): VectorLike {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}

View File

@@ -0,0 +1,255 @@
export type VectorLike = {
x: number;
y: number;
z: number;
};
export type PalmAimInput = {
handedness?: string | null;
indexMetacarpal?: VectorLike | null;
middleMetacarpal?: VectorLike | null;
pinkyMetacarpal?: VectorLike | null;
ringMetacarpal?: VectorLike | null;
wrist?: VectorLike | null;
};
export type PalmAimRay = {
direction: VectorLike;
origin: VectorLike;
};
export type TimedPalmAimRay = PalmAimRay & {
timestamp: number;
};
export type PalmAimLatch = {
isSelecting: boolean;
selectedRay: TimedPalmAimRay | null;
stableRay: TimedPalmAimRay | null;
};
const MIN_AXIS_LENGTH_SQ = 0.000001;
const PALM_AIM_FORWARD_TILT_DEGREES = 40;
const PALM_SURFACE_OFFSET_METERS = 0.035;
export const DEFAULT_PALM_AIM_LATCH_MAX_AGE_MS = 300;
export function computePalmAimRay(input: PalmAimInput): PalmAimRay | null {
const { indexMetacarpal, pinkyMetacarpal, wrist } = input;
if (!indexMetacarpal || !pinkyMetacarpal || !wrist) {
return null;
}
const knuckleCenter = averageVectors([
indexMetacarpal,
input.middleMetacarpal,
input.ringMetacarpal,
pinkyMetacarpal
]);
if (!knuckleCenter) {
return null;
}
const fingerAxis = normalize(subtract(knuckleCenter, wrist));
const acrossPalmAxis = normalize(subtract(pinkyMetacarpal, indexMetacarpal));
if (!fingerAxis || !acrossPalmAxis) {
return null;
}
const direction = normalize(getTiltedPalmDirection(
getPalmNormal(fingerAxis, acrossPalmAxis, input.handedness),
fingerAxis
));
if (!direction) {
return null;
}
const palmCenter = lerp(wrist, knuckleCenter, 0.62);
const origin = add(palmCenter, scale(direction, PALM_SURFACE_OFFSET_METERS));
return { direction, origin };
}
export function createPalmAimLatch(): PalmAimLatch {
return {
isSelecting: false,
selectedRay: null,
stableRay: null
};
}
export function recordStablePalmAimRay(
latch: PalmAimLatch | null | undefined,
ray: PalmAimRay,
timestamp: number
): void {
if (!latch) {
return;
}
if (latch.isSelecting) {
return;
}
latch.stableRay = withTimestamp(ray, timestamp);
}
export function beginPalmAimSelection(
latch: PalmAimLatch | null | undefined,
timestamp: number,
maxAgeMs = DEFAULT_PALM_AIM_LATCH_MAX_AGE_MS
): PalmAimRay | null {
if (!latch) {
return null;
}
latch.isSelecting = true;
const stableRay = getFreshTimedRay(latch.stableRay, timestamp, maxAgeMs);
latch.selectedRay = stableRay ? cloneTimedRay(stableRay) : null;
return latch.selectedRay ? clonePalmAimRay(latch.selectedRay) : null;
}
export function endPalmAimSelection(latch: PalmAimLatch | null | undefined): void {
if (!latch) {
return;
}
latch.isSelecting = false;
latch.selectedRay = null;
}
export function getPalmAimSelectionRay(
latch: PalmAimLatch | null | undefined,
timestamp: number,
maxAgeMs = DEFAULT_PALM_AIM_LATCH_MAX_AGE_MS
): PalmAimRay | null {
if (!latch) {
return null;
}
if (latch.isSelecting && latch.selectedRay) {
return clonePalmAimRay(latch.selectedRay);
}
const stableRay = getFreshTimedRay(latch.stableRay, timestamp, maxAgeMs);
return stableRay ? clonePalmAimRay(stableRay) : null;
}
function getPalmNormal(fingerAxis: VectorLike, acrossPalmAxis: VectorLike, handedness?: string | null): VectorLike {
if (handedness === 'left') {
return cross(acrossPalmAxis, fingerAxis);
}
return cross(fingerAxis, acrossPalmAxis);
}
function getTiltedPalmDirection(palmNormal: VectorLike, fingerAxis: VectorLike): VectorLike {
const tiltRadians = (PALM_AIM_FORWARD_TILT_DEGREES * Math.PI) / 180;
return add(
scale(palmNormal, Math.cos(tiltRadians)),
scale(fingerAxis, Math.sin(tiltRadians))
);
}
function withTimestamp(ray: PalmAimRay, timestamp: number): TimedPalmAimRay {
return {
...clonePalmAimRay(ray),
timestamp
};
}
function cloneTimedRay(ray: TimedPalmAimRay): TimedPalmAimRay {
return {
...clonePalmAimRay(ray),
timestamp: ray.timestamp
};
}
function clonePalmAimRay(ray: PalmAimRay): PalmAimRay {
return {
direction: cloneVector(ray.direction),
origin: cloneVector(ray.origin)
};
}
function cloneVector(vector: VectorLike): VectorLike {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function getFreshTimedRay(
ray: TimedPalmAimRay | null,
timestamp: number,
maxAgeMs: number
): TimedPalmAimRay | null {
if (!ray) {
return null;
}
const ageMs = Math.max(0, timestamp - ray.timestamp);
return ageMs <= maxAgeMs ? ray : null;
}
function averageVectors(vectors: Array<VectorLike | null | undefined>): VectorLike | null {
const usableVectors = vectors.filter(Boolean) as VectorLike[];
if (usableVectors.length === 0) {
return null;
}
const total = usableVectors.reduce(
(sum, vector) => add(sum, vector),
{ x: 0, y: 0, z: 0 }
);
return scale(total, 1 / usableVectors.length);
}
function normalize(vector: VectorLike): VectorLike | null {
const lengthSq = vector.x * vector.x + vector.y * vector.y + vector.z * vector.z;
if (lengthSq < MIN_AXIS_LENGTH_SQ) {
return null;
}
const length = Math.sqrt(lengthSq);
return scale(vector, 1 / length);
}
function add(a: VectorLike, b: VectorLike): VectorLike {
return {
x: a.x + b.x,
y: a.y + b.y,
z: a.z + b.z
};
}
function subtract(a: VectorLike, b: VectorLike): VectorLike {
return {
x: a.x - b.x,
y: a.y - b.y,
z: a.z - b.z
};
}
function scale(vector: VectorLike, scalar: number): VectorLike {
return {
x: vector.x * scalar,
y: vector.y * scalar,
z: vector.z * scalar
};
}
function lerp(a: VectorLike, b: VectorLike, amount: number): VectorLike {
return {
x: a.x + (b.x - a.x) * amount,
y: a.y + (b.y - a.y) * amount,
z: a.z + (b.z - a.z) * amount
};
}
function cross(a: VectorLike, b: VectorLike): VectorLike {
return {
x: a.y * b.z - a.z * b.y,
y: a.z * b.x - a.x * b.z,
z: a.x * b.y - a.y * b.x
};
}

View File

@@ -0,0 +1,52 @@
export type PointerInputMode = 'controller' | 'hand';
export type PointerInputModeCarrier = {
controller?: {
userData?: any;
};
pointerInputMode?: PointerInputMode;
};
export function rememberPointerInputMode(
inputSource: PointerInputModeCarrier,
event: any,
fallbackMode: PointerInputMode
): void {
const eventInputSource = event?.data?.inputSource || event?.inputSource || event?.data;
const nextMode = getPointerInputMode(eventInputSource) || fallbackMode;
inputSource.pointerInputMode = nextMode;
if (!inputSource.controller) {
return;
}
inputSource.controller.userData = {
...inputSource.controller.userData,
vrwpInputSource: inputSource
};
}
export function getPointerInputMode(eventInputSource: any): PointerInputMode | null {
if (!eventInputSource) {
return null;
}
if (eventInputSource.hand) {
return 'hand';
}
if (Array.isArray(eventInputSource.profiles) &&
eventInputSource.profiles.some((profile: string) => profile.toLowerCase().includes('hand'))) {
return 'hand';
}
if (eventInputSource.gamepad || eventInputSource.targetRayMode === 'tracked-pointer') {
return 'controller';
}
return null;
}
export function shouldUseHandPointer(inputSource: PointerInputModeCarrier | undefined): boolean {
return inputSource?.pointerInputMode === 'hand';
}

View File

@@ -0,0 +1,365 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
import {
getSeekProgressFromIntersection,
type VrControlPanel
} from './vr-control-panel.js';
import {
beginPalmAimSelection,
createPalmAimLatch,
endPalmAimSelection,
getPalmAimSelectionRay,
recordStablePalmAimRay,
type PalmAimLatch
} from './hand-aim.js';
import {
DEFAULT_RAY_DIRECTION,
getHandAimRay,
rememberHandedness,
toAimRay,
toPalmAimRay,
type AimRay
} from './hand-aim-three.js';
import {
rememberPointerInputMode,
shouldUseHandPointer,
type PointerInputMode
} from './input-mode.js';
import {
bindOverlayActivity,
createControllerOverlay,
createHandOverlay,
createPointerOverlay,
createWorldPointerOverlay,
POINTER_HIT_SURFACE_OFFSET,
POINTER_LENGTH,
POINTER_MIN_LENGTH,
setPointerOverlayLength,
VrOverlayVisibility
} from './pointer-overlays.js';
export type VrInputRig = {
beginSeekDrag: (controller: any, panel: VrControlPanel | undefined, onSeek: (progress: number) => void) => void;
hideOverlays: () => void;
raycaster: any;
showOverlays: (timestamp?: number) => void;
update: (timestamp: number, hoverTargets?: any[]) => boolean;
};
type ActiveSeekDrag = {
inputSource: VrInputSource;
onSeek: (progress: number) => void;
panel: VrControlPanel;
};
type HandPointerOverlay = {
fallbackPointerOverlay: any;
hand: any;
handAimLatch: PalmAimLatch;
inputSource: VrInputSource;
pointerOverlay: any;
};
type VrInputSource = {
controller: any;
controllerPointerOverlay: any;
hand?: any;
handAimLatch?: PalmAimLatch;
handPointerOverlay?: any;
pointerInputMode: PointerInputMode;
};
const tempMatrix = new THREE.Matrix4();
export function createVrInputRig(scene: any, renderer: any, onSelectStart: (event: any) => void): VrInputRig {
const overlayVisibility = new VrOverlayVisibility();
const handPointerOverlays: HandPointerOverlay[] = [];
const inputSources: VrInputSource[] = [];
const raycaster = createPointerRaycaster();
const hoverRaycaster = createPointerRaycaster();
const dragRaycaster = createPointerRaycaster();
let activeSeekDrag: ActiveSeekDrag | null = null;
for (let index = 0; index < 2; index += 1) {
const controller = renderer.xr.getController(index);
const controllerPointerOverlay = createPointerOverlay(index, overlayVisibility);
const inputSource: VrInputSource = {
controller,
controllerPointerOverlay,
pointerInputMode: 'controller'
};
controller.userData = {
...controller.userData,
vrwpInputSource: inputSource
};
inputSources.push(inputSource);
controller.addEventListener('connected', (event: any) => {
rememberPointerInputMode(inputSource, event, 'controller');
});
controller.addEventListener('selectstart', (event: any) => {
const timestamp = getEventTimestamp(event);
rememberPointerInputMode(inputSource, event, inputSource.pointerInputMode);
if (shouldUseHandPointer(inputSource)) {
beginPalmAimSelection(controller.userData?.vrwpHandAimLatch, timestamp);
}
overlayVisibility.show(timestamp);
onSelectStart(event);
});
controller.addEventListener('selectend', () => {
endPalmAimSelection(controller.userData?.vrwpHandAimLatch);
if (activeSeekDrag?.inputSource.controller === controller) {
activeSeekDrag = null;
}
});
controller.addEventListener('select', () => {
endPalmAimSelection(controller.userData?.vrwpHandAimLatch);
});
bindOverlayActivity(controller, overlayVisibility);
controller.add(controllerPointerOverlay);
scene.add(controller);
const grip = renderer.xr.getControllerGrip?.(index);
if (grip) {
grip.addEventListener('connected', (event: any) => {
rememberPointerInputMode(inputSource, event, 'controller');
});
bindOverlayActivity(grip, overlayVisibility);
grip.add(createControllerOverlay(index, overlayVisibility));
scene.add(grip);
}
const hand = renderer.xr.getHand?.(index);
if (hand) {
const handAimLatch = createPalmAimLatch();
inputSource.hand = hand;
inputSource.handAimLatch = handAimLatch;
controller.userData = {
...controller.userData,
vrwpHand: hand,
vrwpHandAimLatch: handAimLatch
};
hand.userData = {
...hand.userData,
vrwpAimLatch: handAimLatch
};
bindOverlayActivity(hand, overlayVisibility);
rememberHandedness(hand, { data: hand.inputState });
createHandOverlay(hand, index, overlayVisibility);
hand.addEventListener?.('connected', (event: any) => {
rememberPointerInputMode(inputSource, event, 'hand');
rememberHandedness(hand, event);
createHandOverlay(hand, index, overlayVisibility);
overlayVisibility.show();
});
scene.add(hand);
const handPointerOverlay = createWorldPointerOverlay(index, overlayVisibility);
inputSource.handPointerOverlay = handPointerOverlay;
scene.add(handPointerOverlay);
handPointerOverlays.push({
fallbackPointerOverlay: controllerPointerOverlay,
hand,
handAimLatch,
inputSource,
pointerOverlay: handPointerOverlay
});
}
}
overlayVisibility.hideImmediately();
return {
beginSeekDrag: (controller: any, panel: VrControlPanel | undefined, onSeek: (progress: number) => void) => {
const inputSource = getInputSourceByController(inputSources, controller);
if (!inputSource || !panel?.seekBarHitAreaMesh) {
activeSeekDrag = null;
return;
}
activeSeekDrag = { inputSource, onSeek, panel };
},
hideOverlays: () => overlayVisibility.hideImmediately(),
raycaster,
showOverlays: (timestamp?: number) => overlayVisibility.show(timestamp),
update: (timestamp: number, hoverTargets: any[] = []) => {
updateHandPointerOverlays(handPointerOverlays, timestamp);
updateActiveSeekDrag(activeSeekDrag, dragRaycaster, timestamp);
const isHovering = updateInputPointerIntersections(inputSources, hoverTargets, hoverRaycaster, timestamp);
if (isHovering) {
overlayVisibility.show(timestamp);
}
overlayVisibility.update(timestamp);
return isHovering;
}
};
}
function createPointerRaycaster(): any {
const raycaster = new THREE.Raycaster();
raycaster.near = 0.1;
raycaster.far = POINTER_LENGTH;
return raycaster;
}
function getInputSourceByController(inputSources: VrInputSource[], controller: any): VrInputSource | undefined {
return inputSources.find((inputSource) => inputSource.controller === controller);
}
function updateInputPointerIntersections(
inputSources: VrInputSource[],
hoverTargets: any[],
hoverRaycaster: any,
timestamp: number
): boolean {
let isHoveringAnyTarget = false;
inputSources.forEach((inputSource) => {
resetInputPointerLengths(inputSource);
const aimRay = getInputSourceAimRay(inputSource, timestamp);
const pointerOverlay = getActivePointerOverlay(inputSource);
if (!aimRay || !pointerOverlay || hoverTargets.length === 0) {
return;
}
hoverRaycaster.ray.origin.copy(aimRay.origin);
hoverRaycaster.ray.direction.copy(aimRay.direction);
const intersections = hoverRaycaster.intersectObjects(hoverTargets, true);
if (intersections.length === 0) {
return;
}
isHoveringAnyTarget = true;
setPointerOverlayLength(pointerOverlay, getPointerIntersectionLength(intersections[0].distance));
});
return isHoveringAnyTarget;
}
function updateActiveSeekDrag(activeSeekDrag: ActiveSeekDrag | null, dragRaycaster: any, timestamp: number): void {
if (!activeSeekDrag?.panel.seekBarHitAreaMesh) {
return;
}
const aimRay = getInputSourceAimRay(activeSeekDrag.inputSource, timestamp, { preferLiveHandAim: true });
if (!aimRay) {
return;
}
dragRaycaster.ray.origin.copy(aimRay.origin);
dragRaycaster.ray.direction.copy(aimRay.direction);
const intersections = dragRaycaster.intersectObject(activeSeekDrag.panel.seekBarHitAreaMesh, true);
if (intersections.length === 0) {
return;
}
activeSeekDrag.onSeek(getSeekProgressFromIntersection(activeSeekDrag.panel, intersections[0].point));
}
function getInputSourceAimRay(
inputSource: VrInputSource,
timestamp: number,
{ preferLiveHandAim = false }: { preferLiveHandAim?: boolean } = {}
): AimRay | null {
if (shouldUseHandPointer(inputSource) && inputSource.hand && inputSource.handAimLatch) {
if (preferLiveHandAim) {
const handRay = getHandAimRay(inputSource.hand);
if (handRay) {
return handRay;
}
}
const latchedRay = getPalmAimSelectionRay(inputSource.handAimLatch, timestamp);
if (latchedRay) {
return toAimRay(latchedRay);
}
const handRay = getHandAimRay(inputSource.hand);
if (handRay) {
return handRay;
}
}
return getControllerAimRay(inputSource.controller);
}
function getControllerAimRay(controller: any): AimRay | null {
if (!controller) {
return null;
}
controller.updateMatrixWorld();
tempMatrix.identity().extractRotation(controller.matrixWorld);
const origin = new THREE.Vector3().setFromMatrixPosition(controller.matrixWorld);
const direction = new THREE.Vector3(0, 0, -1).applyMatrix4(tempMatrix);
return { direction, origin };
}
function resetInputPointerLengths(inputSource: VrInputSource): void {
setPointerOverlayLength(inputSource.controllerPointerOverlay, POINTER_LENGTH);
if (inputSource.handPointerOverlay) {
setPointerOverlayLength(inputSource.handPointerOverlay, POINTER_LENGTH);
}
}
function getActivePointerOverlay(inputSource: VrInputSource): any {
if (inputSource.handPointerOverlay && inputSource.handPointerOverlay.userData?.vrwpOverlayAvailable !== false) {
return inputSource.handPointerOverlay;
}
if (inputSource.controllerPointerOverlay && inputSource.controllerPointerOverlay.userData?.vrwpOverlayAvailable !== false) {
return inputSource.controllerPointerOverlay;
}
return null;
}
function getPointerIntersectionLength(distance: number): number {
return Math.max(
POINTER_MIN_LENGTH,
Math.min(POINTER_LENGTH, distance - POINTER_HIT_SURFACE_OFFSET)
);
}
function updateHandPointerOverlays(handPointerOverlays: HandPointerOverlay[], timestamp: number): void {
handPointerOverlays.forEach(({ fallbackPointerOverlay, hand, handAimLatch, inputSource, pointerOverlay }) => {
if (!shouldUseHandPointer(inputSource)) {
pointerOverlay.userData = {
...pointerOverlay.userData,
vrwpOverlayAvailable: false
};
fallbackPointerOverlay.userData = {
...fallbackPointerOverlay.userData,
vrwpOverlayAvailable: true
};
return;
}
const currentHandRay = getHandAimRay(hand);
if (currentHandRay) {
recordStablePalmAimRay(handAimLatch, toPalmAimRay(currentHandRay), timestamp);
}
const selectionPalmRay = getPalmAimSelectionRay(handAimLatch, timestamp);
const displayHandRay = selectionPalmRay ? toAimRay(selectionPalmRay) : currentHandRay;
const hasHandRay = Boolean(displayHandRay);
pointerOverlay.userData = {
...pointerOverlay.userData,
vrwpOverlayAvailable: hasHandRay
};
fallbackPointerOverlay.userData = {
...fallbackPointerOverlay.userData,
vrwpOverlayAvailable: !hasHandRay
};
if (!displayHandRay) {
return;
}
pointerOverlay.position.copy(displayHandRay.origin);
pointerOverlay.quaternion.setFromUnitVectors(DEFAULT_RAY_DIRECTION, displayHandRay.direction);
});
}
function getEventTimestamp(event: any): number {
return Number.isFinite(event?.timeStamp) ? event.timeStamp : performance.now();
}

View File

@@ -0,0 +1,328 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
export type PointerOverlayInputSource = {
controllerPointerOverlay: any;
handPointerOverlay?: any;
};
export type VrOverlayVisibilityOptions = {
fadeDurationMs?: number;
hideDelayMs?: number;
};
export const INPUT_OVERLAY_HIDE_DELAY_MS = 2500;
export const INPUT_OVERLAY_FADE_DURATION_MS = 200;
export const INPUT_OVERLAY_RENDER_ORDER = 10000;
export const POINTER_LENGTH = 5;
export const POINTER_MIN_LENGTH = 0.06;
export const POINTER_HIT_SURFACE_OFFSET = 0.015;
const HAND_JOINT_NAMES = [
'wrist',
'thumb-metacarpal',
'thumb-phalanx-proximal',
'thumb-phalanx-distal',
'thumb-tip',
'index-finger-metacarpal',
'index-finger-phalanx-proximal',
'index-finger-phalanx-intermediate',
'index-finger-phalanx-distal',
'index-finger-tip',
'middle-finger-metacarpal',
'middle-finger-phalanx-proximal',
'middle-finger-phalanx-intermediate',
'middle-finger-phalanx-distal',
'middle-finger-tip',
'ring-finger-metacarpal',
'ring-finger-phalanx-proximal',
'ring-finger-phalanx-intermediate',
'ring-finger-phalanx-distal',
'ring-finger-tip',
'pinky-finger-metacarpal',
'pinky-finger-phalanx-proximal',
'pinky-finger-phalanx-intermediate',
'pinky-finger-phalanx-distal',
'pinky-finger-tip'
];
export class VrOverlayVisibility {
private readonly fadeDurationMs: number;
private readonly hideDelayMs: number;
private readonly objects: any[] = [];
private opacity = 0;
private targetOpacity = 0;
private visibleUntil = 0;
constructor({
fadeDurationMs = INPUT_OVERLAY_FADE_DURATION_MS,
hideDelayMs = INPUT_OVERLAY_HIDE_DELAY_MS
}: VrOverlayVisibilityOptions = {}) {
this.fadeDurationMs = fadeDurationMs;
this.hideDelayMs = hideDelayMs;
}
register(object: any): void {
this.objects.push(object);
this.setObjectVisible(object, this.targetOpacity > 0 || this.opacity > 0.001);
this.setObjectOpacity(object, this.opacity);
}
show(timestamp = performance.now()): void {
this.visibleUntil = timestamp + this.hideDelayMs;
this.targetOpacity = 1;
this.objects.forEach((object) => this.setObjectVisible(object, true));
}
hideImmediately(): void {
this.visibleUntil = 0;
this.opacity = 0;
this.targetOpacity = 0;
this.objects.forEach((object) => {
this.setObjectOpacity(object, 0);
this.setObjectVisible(object, false);
});
}
update(timestamp: number): void {
if (this.targetOpacity > 0 && timestamp >= this.visibleUntil) {
this.targetOpacity = 0;
}
const shouldBeVisible = this.targetOpacity > 0 || this.opacity > 0.001;
this.objects.forEach((object) => this.setObjectVisible(object, shouldBeVisible));
if (this.opacity === this.targetOpacity) {
return;
}
const fadeStep = this.fadeDurationMs <= 0
? 1
: Math.min(1, 16.67 / this.fadeDurationMs);
const direction = this.opacity < this.targetOpacity ? 1 : -1;
this.opacity = Math.max(0, Math.min(1, this.opacity + fadeStep * direction));
if ((direction > 0 && this.opacity >= this.targetOpacity) || (direction < 0 && this.opacity <= this.targetOpacity)) {
this.opacity = this.targetOpacity;
}
this.objects.forEach((object) => {
this.setObjectOpacity(object, this.opacity);
this.setObjectVisible(object, this.opacity > 0.001);
});
}
private setObjectVisible(object: any, isVisible: boolean): void {
const objectVisible = isVisible && object.userData?.vrwpOverlayAvailable !== false;
object.visible = objectVisible;
object.traverse?.((child: any) => {
child.visible = objectVisible;
});
}
private setObjectOpacity(object: any, opacity: number): void {
object.traverse?.((child: any) => {
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.filter(Boolean).forEach((material: any) => {
material.opacity = opacity;
material.transparent = true;
material.depthTest = false;
material.depthWrite = false;
material.needsUpdate = true;
});
});
}
}
export function bindOverlayActivity(target: any, overlayVisibility: VrOverlayVisibility): void {
[
'connected',
'disconnected',
'select',
'selectend',
'squeezestart',
'squeeze',
'squeezeend',
'pinchstart',
'pinchend'
].forEach((eventName) => {
target.addEventListener?.(eventName, () => overlayVisibility.show());
});
}
export function createPointerOverlay(index: number, overlayVisibility: VrOverlayVisibility): any {
const group = new THREE.Group();
group.name = `vrPointerOverlay${index}`;
const pointerMaterial = createOverlayLineMaterial(index === 0 ? 0x8fd8ff : 0xffc16b, 0.75);
const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, -POINTER_LENGTH)
]);
const pointerLine = new THREE.Line(pointerGeometry, pointerMaterial);
pointerLine.name = `vrPointerRay${index}`;
pointerLine.renderOrder = INPUT_OVERLAY_RENDER_ORDER;
group.add(pointerLine);
const tipMaterial = createOverlayMeshMaterial(index === 0 ? 0x8fd8ff : 0xffc16b, 0.9);
const tipMesh = new THREE.Mesh(new THREE.SphereGeometry(0.018, 12, 8), tipMaterial);
tipMesh.name = `vrPointerTip${index}`;
tipMesh.position.z = -POINTER_LENGTH;
tipMesh.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 1;
group.add(tipMesh);
group.userData = {
...group.userData,
vrwpPointerLength: POINTER_LENGTH,
vrwpPointerLine: pointerLine,
vrwpPointerTip: tipMesh
};
overlayVisibility.register(group);
return group;
}
export function createWorldPointerOverlay(index: number, overlayVisibility: VrOverlayVisibility): any {
const group = createPointerOverlay(index, overlayVisibility);
group.name = `vrHandPointerOverlay${index}`;
group.userData = {
...group.userData,
vrwpOverlayAvailable: false
};
return group;
}
export function createControllerOverlay(index: number, overlayVisibility: VrOverlayVisibility): any {
const group = new THREE.Group();
group.name = `vrControllerOverlay${index}`;
const material = createOverlayLineMaterial(index === 0 ? 0x8fd8ff : 0xffc16b, 0.8);
const outlineGeometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-0.045, -0.025, -0.08),
new THREE.Vector3(0.045, -0.025, -0.08),
new THREE.Vector3(0.055, 0.025, -0.02),
new THREE.Vector3(0.025, 0.035, 0.05),
new THREE.Vector3(-0.025, 0.035, 0.05),
new THREE.Vector3(-0.055, 0.025, -0.02),
new THREE.Vector3(-0.045, -0.025, -0.08)
]);
const outline = new THREE.Line(outlineGeometry, material);
outline.name = `vrControllerOutline${index}`;
outline.renderOrder = INPUT_OVERLAY_RENDER_ORDER;
group.add(outline);
const originMaterial = createOverlayMeshMaterial(0xffffff, 0.75);
const origin = new THREE.Mesh(new THREE.SphereGeometry(0.012, 10, 6), originMaterial);
origin.name = `vrControllerOrigin${index}`;
origin.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 1;
group.add(origin);
overlayVisibility.register(group);
return group;
}
export function createHandOverlay(hand: any, index: number, overlayVisibility: VrOverlayVisibility): void {
const joints = getHandJoints(hand);
if (joints.length === 0) {
return;
}
const material = createOverlayMeshMaterial(index === 0 ? 0x8fd8ff : 0xffc16b, 0.85);
joints.forEach(({ joint, name }) => {
if (!joint || joint.userData?.vrwpHandOverlayMarker) {
return;
}
const isTip = name.endsWith('tip');
const isWrist = name === 'wrist';
const radius = isWrist ? 0.014 : isTip ? 0.011 : 0.008;
const marker = new THREE.Mesh(new THREE.SphereGeometry(radius, 10, 6), material);
marker.name = `vrHandJointOverlay${index}-${name}`;
marker.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 2;
marker.frustumCulled = false;
joint.add(marker);
joint.userData = {
...joint.userData,
vrwpHandOverlayMarker: marker
};
overlayVisibility.register(marker);
});
}
export function resetInputPointerLengths(inputSource: PointerOverlayInputSource): void {
setPointerOverlayLength(inputSource.controllerPointerOverlay, POINTER_LENGTH);
if (inputSource.handPointerOverlay) {
setPointerOverlayLength(inputSource.handPointerOverlay, POINTER_LENGTH);
}
}
export function getPointerIntersectionLength(distance: number): number {
return Math.max(
POINTER_MIN_LENGTH,
Math.min(POINTER_LENGTH, distance - POINTER_HIT_SURFACE_OFFSET)
);
}
export function setPointerOverlayLength(pointerOverlay: any, length: number): void {
if (!pointerOverlay || pointerOverlay.userData?.vrwpPointerLength === length) {
return;
}
const pointerLine = pointerOverlay.userData?.vrwpPointerLine;
const pointerTip = pointerOverlay.userData?.vrwpPointerTip;
const positionAttribute = pointerLine?.geometry?.getAttribute?.('position') ||
pointerLine?.geometry?.attributes?.position;
if (positionAttribute?.setXYZ) {
positionAttribute.setXYZ(1, 0, 0, -length);
positionAttribute.needsUpdate = true;
pointerLine.geometry.computeBoundingSphere?.();
}
if (pointerTip) {
pointerTip.position.z = -length;
}
pointerOverlay.userData = {
...pointerOverlay.userData,
vrwpPointerLength: length
};
}
export function createOverlayLineMaterial(color: number, opacity: number): any {
return new THREE.LineBasicMaterial({
color,
depthTest: false,
depthWrite: false,
opacity,
transparent: true
});
}
export function createOverlayMeshMaterial(color: number, opacity: number): any {
return new THREE.MeshBasicMaterial({
color,
depthTest: false,
depthWrite: false,
opacity,
transparent: true
});
}
function getHandJoints(hand: any): Array<{ joint: any; name: string }> {
const joints = hand?.joints;
if (!joints) {
return [];
}
const namedJoints = HAND_JOINT_NAMES
.map((name) => ({ joint: joints[name], name }))
.filter(({ joint }) => Boolean(joint));
if (namedJoints.length > 0) {
return namedJoints;
}
return Object.entries(joints)
.map(([name, joint]) => ({ joint, name }))
.filter(({ joint }) => Boolean(joint));
}

View File

@@ -0,0 +1,230 @@
import type { MediaCapabilities } from '../media/media-adapter.js';
import {
createStaticVrButtonTexture,
createVrButtonTexture,
updateLoopButtonTexture,
updatePlayPauseButtonTexture,
updateVolumeButtonTexture,
type VrButtonTexture
} from './vr-panel-button-textures.js';
import {
VR_PANEL_BUTTON_LAYOUTS,
WORLD_SEEK_BAR_WIDTH
} from './vr-panel-layout.js';
import {
createButtonMesh,
createPanelBackground,
createSeekBarMeshes,
createVrPanelGroup
} from './vr-panel-meshes.js';
export type VrControlPanel = {
exitButtonMesh: any;
forwardButtonMesh?: any;
group: any;
interactables: any[];
loopButtonCanvas?: HTMLCanvasElement;
loopButtonContext?: CanvasRenderingContext2D | null;
loopButtonMesh?: any;
loopButtonTexture?: any;
playPauseButtonCanvas?: HTMLCanvasElement;
playPauseButtonContext?: CanvasRenderingContext2D | null;
playPauseButtonMesh?: any;
playPauseButtonTexture?: any;
rewindButtonMesh?: any;
seekBarHitAreaMesh?: any;
seekBarProgressMesh?: any;
seekBarTrackMesh?: any;
volumeButtonCanvas?: HTMLCanvasElement;
volumeButtonContext?: CanvasRenderingContext2D | null;
volumeButtonMesh?: any;
volumeButtonTexture?: any;
};
const DEFAULT_MEDIA_CAPABILITIES: MediaCapabilities = {
audio: true,
carousel: false,
dynamicTexture: true,
navigation: true,
playback: true,
timeline: true
};
export function createVrControlPanel(
scene: any,
title: string,
mediaCapabilities: MediaCapabilities = DEFAULT_MEDIA_CAPABILITIES
): VrControlPanel {
const group = createVrPanelGroup(scene);
const interactables: any[] = [];
const panelMesh = createPanelBackground(title);
group.add(panelMesh);
interactables.push(panelMesh);
let seekBarTrackMesh;
let seekBarProgressMesh;
let seekBarHitAreaMesh;
if (mediaCapabilities.timeline) {
const seekBarMeshes = createSeekBarMeshes();
seekBarTrackMesh = seekBarMeshes.trackMesh;
seekBarProgressMesh = seekBarMeshes.progressMesh;
seekBarHitAreaMesh = seekBarMeshes.hitAreaMesh;
group.add(seekBarTrackMesh);
group.add(seekBarProgressMesh);
group.add(seekBarHitAreaMesh);
interactables.push(seekBarHitAreaMesh);
}
let playPauseButton: VrButtonTexture | undefined;
let playPauseButtonMesh;
let loopButton: VrButtonTexture | undefined;
let loopButtonMesh;
let rewindButtonMesh;
let forwardButtonMesh;
if (mediaCapabilities.playback) {
playPauseButton = createVrButtonTexture();
playPauseButtonMesh = createButtonMesh({
...VR_PANEL_BUTTON_LAYOUTS.playPause,
texture: playPauseButton.texture
});
group.add(playPauseButtonMesh);
interactables.push(playPauseButtonMesh);
loopButton = createVrButtonTexture();
updateLoopButtonTexture(loopButton, false);
loopButtonMesh = createButtonMesh({
...VR_PANEL_BUTTON_LAYOUTS.loop,
texture: loopButton.texture
});
group.add(loopButtonMesh);
interactables.push(loopButtonMesh);
}
if (mediaCapabilities.navigation) {
rewindButtonMesh = createButtonMesh({
...VR_PANEL_BUTTON_LAYOUTS.rewind,
texture: mediaCapabilities.carousel
? createStaticVrButtonTexture('chevron-left')
: createStaticVrButtonTexture('rotate-ccw', '15')
});
group.add(rewindButtonMesh);
interactables.push(rewindButtonMesh);
forwardButtonMesh = createButtonMesh({
...VR_PANEL_BUTTON_LAYOUTS.forward,
texture: mediaCapabilities.carousel
? createStaticVrButtonTexture('chevron-right')
: createStaticVrButtonTexture('rotate-cw', '15')
});
group.add(forwardButtonMesh);
interactables.push(forwardButtonMesh);
}
const exitButtonMesh = createButtonMesh({
...VR_PANEL_BUTTON_LAYOUTS.exit,
texture: createStaticVrButtonTexture('arrow-left')
});
group.add(exitButtonMesh);
interactables.push(exitButtonMesh);
let volumeButton: VrButtonTexture | undefined;
let volumeButtonMesh;
if (mediaCapabilities.audio) {
volumeButton = createVrButtonTexture();
volumeButtonMesh = createButtonMesh({
...VR_PANEL_BUTTON_LAYOUTS.volume,
texture: volumeButton.texture
});
group.add(volumeButtonMesh);
interactables.push(volumeButtonMesh);
}
group.visible = false;
return {
exitButtonMesh,
forwardButtonMesh,
group,
interactables,
loopButtonCanvas: loopButton?.canvas,
loopButtonContext: loopButton?.context,
loopButtonMesh,
loopButtonTexture: loopButton?.texture,
playPauseButtonCanvas: playPauseButton?.canvas,
playPauseButtonContext: playPauseButton?.context,
playPauseButtonMesh,
playPauseButtonTexture: playPauseButton?.texture,
rewindButtonMesh,
seekBarHitAreaMesh,
seekBarProgressMesh,
seekBarTrackMesh,
volumeButtonCanvas: volumeButton?.canvas,
volumeButtonContext: volumeButton?.context,
volumeButtonMesh,
volumeButtonTexture: volumeButton?.texture
};
}
export function updateVrPlayPauseButtonIcon(panel: VrControlPanel | undefined, isPausedOrEnded: boolean): void {
updatePlayPauseButtonTexture({
canvas: panel?.playPauseButtonCanvas,
context: panel?.playPauseButtonContext,
texture: panel?.playPauseButtonTexture
}, isPausedOrEnded);
}
export function updateVrLoopButtonIcon(panel: VrControlPanel | undefined, isLooping: boolean): void {
updateLoopButtonTexture({
canvas: panel?.loopButtonCanvas,
context: panel?.loopButtonContext,
texture: panel?.loopButtonTexture
}, isLooping);
}
export function updateVrVolumeButtonIcon(panel: VrControlPanel | undefined, isMuted: boolean): void {
updateVolumeButtonTexture({
canvas: panel?.volumeButtonCanvas,
context: panel?.volumeButtonContext,
texture: panel?.volumeButtonTexture
}, isMuted);
}
export function updateVrSeekBarAppearance(panel: VrControlPanel | undefined, progress: number | null): void {
if (!panel?.seekBarProgressMesh) return;
if (progress === null) {
panel.seekBarProgressMesh.scale.x = 0.0001;
panel.seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
return;
}
const normalizedProgress = Math.max(0.0001, Math.min(1, progress));
panel.seekBarProgressMesh.scale.x = normalizedProgress;
panel.seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2 + (WORLD_SEEK_BAR_WIDTH * normalizedProgress) / 2;
}
export function setVrPanelOpacity(panel: VrControlPanel | undefined, opacity: number): void {
if (!panel) return;
panel.group.children.forEach((child: any) => {
if (child.material && Object.prototype.hasOwnProperty.call(child.material, 'opacity')) {
child.material.opacity = opacity;
}
});
}
export function hideVrPanelImmediately(panel: VrControlPanel | undefined): void {
if (!panel) return;
setVrPanelOpacity(panel, 0);
panel.group.visible = false;
}
export function getSeekProgressFromIntersection(panel: VrControlPanel | undefined, intersectionPoint: any): number {
if (!panel?.seekBarTrackMesh) return 0;
const localPoint = panel.seekBarTrackMesh.worldToLocal(intersectionPoint.clone());
const normalizedPosition = (localPoint.x + WORLD_SEEK_BAR_WIDTH / 2) / WORLD_SEEK_BAR_WIDTH;
return Math.max(0, Math.min(1, normalizedPosition));
}

View File

@@ -0,0 +1,127 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
import {
getSeekProgressFromIntersection,
type VrControlPanel
} from './vr-control-panel.js';
import { getPalmAimSelectionRay } from './hand-aim.js';
import {
getHandAimRay,
toAimRay,
type AimRay
} from './hand-aim-three.js';
import { shouldUseHandPointer } from './input-mode.js';
type VrControllerSelectionOptions = {
beginSeekDrag?: (controller: any) => void;
exitVr: () => void;
forward: () => void;
hidePanel: () => void;
isPanelVisible: () => boolean;
raycaster: any;
rewind: () => void;
seek: (progress: number) => void;
showPanel: () => void;
toggleLoop: () => void;
toggleMute: () => void;
togglePlayPause: () => void;
uiElements: any[];
vrPanel: VrControlPanel | undefined;
};
const tempMatrix = new THREE.Matrix4();
export function handleVrControllerSelect(event: any, options: VrControllerSelectionOptions): void {
const controller = event.target;
if (!options.raycaster) return;
applySelectionRay(controller, options.raycaster);
const directIntersects = options.raycaster.intersectObjects(options.uiElements, true);
if (directIntersects.length === 0) {
togglePanel(options);
return;
}
const firstIntersected = directIntersects[0].object;
const intersectionPoint = directIntersects[0].point;
if (firstIntersected.name === 'vrPlayPauseButton') {
options.togglePlayPause();
options.showPanel();
return;
}
if (firstIntersected.name === 'vrRewindButton') {
options.rewind();
options.showPanel();
return;
}
if (firstIntersected.name === 'vrForwardButton') {
options.forward();
options.showPanel();
return;
}
if (firstIntersected.name === 'vrExitButton') {
options.exitVr();
options.showPanel();
return;
}
if (firstIntersected.name === 'vrVolumeButton') {
options.toggleMute();
options.showPanel();
return;
}
if (firstIntersected.name === 'vrLoopButton') {
options.toggleLoop();
options.showPanel();
return;
}
if (firstIntersected.name === 'seekBarHitArea') {
options.showPanel();
options.seek(getSeekProgressFromIntersection(options.vrPanel, intersectionPoint));
options.beginSeekDrag?.(controller);
return;
}
togglePanel(options);
}
function togglePanel(options: VrControllerSelectionOptions): void {
if (options.isPanelVisible()) {
options.hidePanel();
} else {
options.showPanel();
}
}
function applySelectionRay(controller: any, raycaster: any): void {
const handRay = shouldUseHandPointer(controller.userData?.vrwpInputSource)
? getSelectionHandAimRay(controller) || getHandAimRay(controller.userData?.vrwpHand)
: null;
if (handRay) {
raycaster.ray.origin.copy(handRay.origin);
raycaster.ray.direction.copy(handRay.direction);
return;
}
controller.updateMatrixWorld();
tempMatrix.identity().extractRotation(controller.matrixWorld);
raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
}
function getSelectionHandAimRay(controller: any): AimRay | null {
const latch = controller.userData?.vrwpHandAimLatch ||
controller.userData?.vrwpHand?.userData?.vrwpAimLatch;
if (!latch) {
return null;
}
const palmAimRay = getPalmAimSelectionRay(latch, performance.now());
return palmAimRay ? toAimRay(palmAimRay) : null;
}

View File

@@ -0,0 +1,123 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
import { drawLucideIcon, type LucideIconName } from '../dom/icons.js';
import { createLucideButtonTexture, drawRoundedRect } from '../rendering/three-utils.js';
import {
FIGMA_CORNER_RADIUS_PX,
FIGMA_PANEL_HEIGHT_PX,
FIGMA_PANEL_WIDTH_PX,
FIGMA_TITLE_FONT_SIZE_PX,
FIGMA_TITLE_MARGIN_TOP_PX,
PANEL_TEXTURE_HEIGHT,
PANEL_TEXTURE_WIDTH,
VR_BUTTON_ICON_SIZE,
VR_BUTTON_TEXTURE_SIZE
} from './vr-panel-layout.js';
export type VrButtonTextureControls = {
canvas?: HTMLCanvasElement;
context?: CanvasRenderingContext2D | null;
texture?: any;
};
export type VrButtonTexture = {
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D | null;
texture: any;
};
export function createVrButtonTexture(): VrButtonTexture {
const canvas = document.createElement('canvas');
canvas.width = VR_BUTTON_TEXTURE_SIZE;
canvas.height = VR_BUTTON_TEXTURE_SIZE;
const context = canvas.getContext('2d');
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
return {
canvas,
context,
texture
};
}
export function createStaticVrButtonTexture(iconName: LucideIconName, label?: string): any {
return createLucideButtonTexture(iconName, '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, label);
}
export function createPanelBackgroundTexture(title: string): any {
const panelCanvas = document.createElement('canvas');
panelCanvas.width = PANEL_TEXTURE_WIDTH;
panelCanvas.height = PANEL_TEXTURE_HEIGHT;
const panelCtx = panelCanvas.getContext('2d');
if (!panelCtx) {
throw new Error('Unable to create 2D canvas context for VR control panel.');
}
panelCtx.fillStyle = 'rgba(0, 0, 0, 0.7)';
const textureCornerRadius = FIGMA_CORNER_RADIUS_PX * (PANEL_TEXTURE_WIDTH / FIGMA_PANEL_WIDTH_PX);
drawRoundedRect(panelCtx, 0, 0, PANEL_TEXTURE_WIDTH, PANEL_TEXTURE_HEIGHT, textureCornerRadius, true, false);
const titleFontSizeTexturePx = Math.round(FIGMA_TITLE_FONT_SIZE_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX));
panelCtx.fillStyle = '#ffffff';
panelCtx.font = `500 ${titleFontSizeTexturePx}px Helvetica, Arial, sans-serif`;
panelCtx.textAlign = 'center';
panelCtx.textBaseline = 'top';
const titleMarginTopTexturePx = FIGMA_TITLE_MARGIN_TOP_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX);
panelCtx.fillText(title, PANEL_TEXTURE_WIDTH / 2, titleMarginTopTexturePx);
const panelTexture = new THREE.CanvasTexture(panelCanvas);
panelTexture.minFilter = THREE.LinearFilter;
panelTexture.needsUpdate = true;
return panelTexture;
}
export function updatePlayPauseButtonTexture(
controls: VrButtonTextureControls,
isPausedOrEnded: boolean
): void {
if (!controls.context || !controls.canvas || !controls.texture) return;
const { canvas, context, texture } = controls;
context.clearRect(0, 0, canvas.width, canvas.height);
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
drawLucideIcon(context, isPausedOrEnded ? 'play' : 'pause', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
texture.needsUpdate = true;
}
export function updateLoopButtonTexture(
controls: VrButtonTextureControls,
isLooping: boolean
): void {
if (!controls.context || !controls.canvas || !controls.texture) return;
drawVrLoopButtonIcon(controls.context, controls.canvas, isLooping);
controls.texture.needsUpdate = true;
}
export function updateVolumeButtonTexture(
controls: VrButtonTextureControls,
isMuted: boolean
): void {
if (!controls.context || !controls.canvas || !controls.texture) return;
const { canvas, context, texture } = controls;
context.clearRect(0, 0, canvas.width, canvas.height);
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
drawLucideIcon(context, isMuted ? 'volume-x' : 'volume-2', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
texture.needsUpdate = true;
}
function drawVrLoopButtonIcon(
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
isLooping: boolean
): void {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (isLooping) {
drawRoundedRect(ctx, 10, 10, canvas.width - 20, canvas.height - 20, 28, 'rgba(125, 211, 252, 0.24)', false);
}
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
drawLucideIcon(ctx, 'repeat', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, isLooping ? '#7dd3fc' : '#ffffff', 2);
}

View File

@@ -0,0 +1,74 @@
export type VrPanelButtonLayout = {
centerX: number;
centerY: number;
name: string;
size: number;
};
export const FIGMA_PANEL_WIDTH_PX = 450;
export const FIGMA_PANEL_HEIGHT_PX = 132;
export const FIGMA_CORNER_RADIUS_PX = 30;
export const FIGMA_TITLE_FONT_SIZE_PX = 14;
export const FIGMA_TITLE_MARGIN_TOP_PX = 20;
export const FIGMA_SEEK_BAR_WIDTH_PX = 386;
export const FIGMA_SEEK_BAR_HEIGHT_PX = 5;
export const FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX = (FIGMA_PANEL_HEIGHT_PX / 2) - 54;
export const WORLD_PANEL_WIDTH = 1.5;
export const SCALE_FACTOR = WORLD_PANEL_WIDTH / FIGMA_PANEL_WIDTH_PX;
export const WORLD_PANEL_HEIGHT = FIGMA_PANEL_HEIGHT_PX * SCALE_FACTOR;
export const WORLD_SEEK_BAR_WIDTH = FIGMA_SEEK_BAR_WIDTH_PX * SCALE_FACTOR;
export const WORLD_SEEK_BAR_TRACK_HEIGHT = FIGMA_SEEK_BAR_HEIGHT_PX * SCALE_FACTOR;
export const WORLD_SEEK_BAR_PROGRESS_HEIGHT = (FIGMA_SEEK_BAR_HEIGHT_PX - 1) * SCALE_FACTOR;
export const WORLD_SEEK_BAR_Y_OFFSET = FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX * SCALE_FACTOR;
export const WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER = 12;
export const PANEL_TEXTURE_WIDTH = 1024;
export const PANEL_TEXTURE_HEIGHT = Math.round(PANEL_TEXTURE_WIDTH * (FIGMA_PANEL_HEIGHT_PX / FIGMA_PANEL_WIDTH_PX));
export const VR_BUTTON_TEXTURE_SIZE = 128;
export const VR_BUTTON_ICON_SIZE = 82;
export const VR_PANEL_POSITION = {
x: 0,
y: 0.5,
z: -1.8
};
export const VR_PANEL_BUTTON_LAYOUTS = {
exit: {
centerX: 42,
centerY: 90,
name: 'vrExitButton',
size: 44
},
forward: {
centerX: 281,
centerY: 90,
name: 'vrForwardButton',
size: 44
},
loop: {
centerX: 352,
centerY: 90,
name: 'vrLoopButton',
size: 44
},
playPause: {
centerX: 225,
centerY: 90,
name: 'vrPlayPauseButton',
size: 44
},
rewind: {
centerX: 169,
centerY: 90,
name: 'vrRewindButton',
size: 44
},
volume: {
centerX: 408,
centerY: 90,
name: 'vrVolumeButton',
size: 44
}
} satisfies Record<string, VrPanelButtonLayout>;

View File

@@ -0,0 +1,106 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
import {
FIGMA_PANEL_HEIGHT_PX,
FIGMA_PANEL_WIDTH_PX,
SCALE_FACTOR,
VR_PANEL_POSITION,
WORLD_PANEL_HEIGHT,
WORLD_PANEL_WIDTH,
WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER,
WORLD_SEEK_BAR_PROGRESS_HEIGHT,
WORLD_SEEK_BAR_TRACK_HEIGHT,
WORLD_SEEK_BAR_WIDTH,
WORLD_SEEK_BAR_Y_OFFSET,
type VrPanelButtonLayout
} from './vr-panel-layout.js';
import { createPanelBackgroundTexture } from './vr-panel-button-textures.js';
export type ButtonMeshOptions = VrPanelButtonLayout & {
texture: any;
};
export type SeekBarMeshes = {
hitAreaMesh: any;
progressMesh: any;
trackMesh: any;
};
export function createVrPanelGroup(scene: any): any {
const group = new THREE.Group();
group.position.set(VR_PANEL_POSITION.x, VR_PANEL_POSITION.y, VR_PANEL_POSITION.z);
group.rotation.x = 0;
scene.add(group);
return group;
}
export function createPanelBackground(title: string): any {
const panelMaterial = new THREE.MeshBasicMaterial({
map: createPanelBackgroundTexture(title),
transparent: true,
opacity: 0,
depthWrite: false
});
const panelGeometry = new THREE.PlaneGeometry(WORLD_PANEL_WIDTH, WORLD_PANEL_HEIGHT);
const panelMesh = new THREE.Mesh(panelGeometry, panelMaterial);
panelMesh.name = 'vrControlPanelBackground';
panelMesh.renderOrder = 0;
return panelMesh;
}
export function createSeekBarMeshes(): SeekBarMeshes {
const trackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 });
const trackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
const trackMesh = new THREE.Mesh(trackGeometry, trackMaterial);
trackMesh.name = 'seekBarTrackVisual';
trackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
trackMesh.position.z = 0.01;
trackMesh.renderOrder = 1;
const progressMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 });
const progressGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_PROGRESS_HEIGHT);
const progressMesh = new THREE.Mesh(progressGeometry, progressMaterial);
progressMesh.name = 'seekBarProgressVisual';
progressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + SCALE_FACTOR;
progressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
progressMesh.position.z = 0.015;
progressMesh.scale.x = 0.001;
progressMesh.renderOrder = 2;
const hitAreaGeometry = new THREE.PlaneGeometry(
WORLD_SEEK_BAR_WIDTH,
WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER
);
const hitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 });
const hitAreaMesh = new THREE.Mesh(hitAreaGeometry, hitAreaMaterial);
hitAreaMesh.name = 'seekBarHitArea';
hitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
hitAreaMesh.position.z = 0.012;
hitAreaMesh.renderOrder = 2;
return {
hitAreaMesh,
progressMesh,
trackMesh
};
}
export function createButtonMesh({ centerX, centerY, name, size, texture }: ButtonMeshOptions): any {
const buttonWorldSize = size * SCALE_FACTOR;
const buttonMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 0,
depthWrite: false
});
const buttonGeometry = new THREE.PlaneGeometry(buttonWorldSize, buttonWorldSize);
const buttonMesh = new THREE.Mesh(buttonGeometry, buttonMaterial);
buttonMesh.name = name;
buttonMesh.renderOrder = 3;
const worldButtonOffsetX = (centerX - FIGMA_PANEL_WIDTH_PX / 2) * SCALE_FACTOR;
const worldButtonOffsetY = -(centerY - FIGMA_PANEL_HEIGHT_PX / 2) * SCALE_FACTOR;
buttonMesh.position.set(worldButtonOffsetX, worldButtonOffsetY, 0.02);
return buttonMesh;
}

View File

@@ -0,0 +1,121 @@
import {
hideVrPanelImmediately,
setVrPanelOpacity,
type VrControlPanel
} from './vr-control-panel.js';
const FADE_DURATION_MS = 200;
const AUTO_HIDE_DELAY_MS = 10000;
export class VrPanelVisibility {
private hideTimeout: number | undefined;
private lastFadeTimestamp = 0;
private opacity = 0;
private panel: VrControlPanel | undefined;
private targetOpacity = 0;
private fading = false;
get isFading(): boolean {
return this.fading;
}
get isVisible(): boolean {
return !!(this.panel?.group.visible && this.opacity > 0.01);
}
setPanel(panel: VrControlPanel): void {
this.panel = panel;
this.hideImmediately();
}
show(): void {
this.showWithAutoHide(true);
}
showPersistent(): void {
this.showWithAutoHide(false);
}
private showWithAutoHide(shouldAutoHide: boolean): void {
if (this.panel) this.panel.group.visible = true;
this.clearHideTimeout();
if (this.targetOpacity !== 1.0 || this.opacity < 1.0) {
this.targetOpacity = 1.0;
this.startFade();
}
if (shouldAutoHide) {
this.hideTimeout = window.setTimeout(() => this.hide(), AUTO_HIDE_DELAY_MS);
}
}
hide(): void {
this.clearHideTimeout();
if (this.targetOpacity !== 0.0 || this.opacity > 0.0) {
this.targetOpacity = 0.0;
this.startFade();
}
}
hideImmediately(): void {
this.clearHideTimeout();
this.targetOpacity = 0;
this.opacity = 0;
this.fading = false;
hideVrPanelImmediately(this.panel);
}
updateFade(timestamp: number): void {
if (!this.panel) return;
if (this.lastFadeTimestamp === 0) this.lastFadeTimestamp = timestamp;
const deltaTime = (timestamp - this.lastFadeTimestamp) / 1000;
this.lastFadeTimestamp = timestamp;
const fadeSpeed = 1 / (FADE_DURATION_MS / 1000);
let opacityChanged = false;
if (this.opacity < this.targetOpacity) {
this.opacity += fadeSpeed * deltaTime;
if (this.opacity >= this.targetOpacity) {
this.opacity = this.targetOpacity;
this.fading = false;
}
opacityChanged = true;
} else if (this.opacity > this.targetOpacity) {
this.opacity -= fadeSpeed * deltaTime;
if (this.opacity <= this.targetOpacity) {
this.opacity = this.targetOpacity;
this.fading = false;
if (this.opacity === 0) this.panel.group.visible = false;
}
opacityChanged = true;
} else {
this.fading = false;
}
if (opacityChanged) {
setVrPanelOpacity(this.panel, this.opacity);
}
if (this.fading) {
requestAnimationFrame((nextTimestamp) => this.updateFade(nextTimestamp));
}
}
private clearHideTimeout(): void {
if (this.hideTimeout !== undefined) {
clearTimeout(this.hideTimeout);
this.hideTimeout = undefined;
}
}
private startFade(): void {
if (!this.fading) {
this.fading = true;
this.lastFadeTimestamp = 0;
requestAnimationFrame((timestamp) => this.updateFade(timestamp));
}
}
}

View File

@@ -0,0 +1,31 @@
const statusElement = document.querySelector('[data-demo-xr-status]');
if (statusElement) {
updateXrStatus(statusElement);
}
async function updateXrStatus(element) {
if (!window.isSecureContext) {
element.textContent = 'Immersive WebXR is blocked on this origin. Use HTTPS, a trusted tunnel, or a deployed CDN URL for headset testing.';
element.dataset.state = 'blocked';
return;
}
if (!navigator.xr) {
element.textContent = 'Immersive WebXR is unavailable in this browser.';
element.dataset.state = 'blocked';
return;
}
try {
const supported = await navigator.xr.isSessionSupported('immersive-vr');
element.textContent = supported
? 'Immersive WebXR is available. Use the player button to enter VR.'
: 'This browser reports that immersive-vr is not supported.';
element.dataset.state = supported ? 'ready' : 'blocked';
} catch (error) {
element.textContent = 'Unable to check immersive-vr support. See the browser console for details.';
element.dataset.state = 'blocked';
console.error('DEMO_XR_STATUS_ERROR:', error);
}
}

162
test-pages/demo.css Normal file
View File

@@ -0,0 +1,162 @@
:root {
color-scheme: light;
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
color: #151515;
background: #f4f4f2;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
a {
color: inherit;
}
.demo-page {
min-height: 100vh;
padding: 32px;
}
.demo-shell {
width: min(100%, 1040px);
margin: 0 auto;
}
.demo-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.demo-brand {
margin: 0;
font-size: 2.5rem;
line-height: 1;
}
.demo-kicker {
margin: 8px 0 0;
max-width: 700px;
color: #555;
font-size: 1rem;
}
.demo-back {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 0 14px;
border: 1px solid #c7c7c0;
border-radius: 6px;
background: #fff;
text-decoration: none;
font-weight: 650;
}
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 28px;
}
.demo-card {
display: grid;
gap: 10px;
min-height: 150px;
padding: 18px;
border: 1px solid #d5d5cf;
border-radius: 8px;
background: #fff;
text-decoration: none;
transition: transform 0.15s ease, border-color 0.15s ease;
}
.demo-card:hover {
transform: translateY(-2px);
border-color: #83837a;
}
.demo-card h2 {
margin: 0;
font-size: 1.125rem;
}
.demo-card p,
.demo-meta {
margin: 0;
color: #5c5c55;
}
.demo-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
color: #66665f;
font-size: 0.875rem;
font-weight: 650;
}
.demo-player {
display: grid;
gap: 14px;
}
.demo-player-frame {
width: min(100%, 960px);
}
.demo-note {
max-width: 760px;
margin: 18px 0 0;
color: #606058;
font-size: 0.95rem;
}
.demo-xr-status {
max-width: 760px;
margin: 0 0 10px;
padding: 10px 12px;
border: 1px solid #d5d5cf;
border-radius: 6px;
background: #fff;
color: #4f4f48;
font-size: 0.95rem;
}
.demo-xr-status[data-state="blocked"] {
border-color: #d7a13a;
background: #fff8e8;
color: #5f4515;
}
.demo-xr-status[data-state="ready"] {
border-color: #7eb07b;
background: #eff8ef;
color: #275425;
}
@media (max-width: 640px) {
.demo-page {
padding: 20px;
}
.demo-topbar {
align-items: flex-start;
flex-direction: column;
}
.demo-brand {
font-size: 2rem;
}
}

56
test-pages/index.html Normal file
View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>VR Web Player Test Pages</title>
<link rel="stylesheet" href="./demo.css">
</head>
<body>
<main class="demo-page">
<div class="demo-shell">
<header class="demo-topbar">
<div>
<h1 class="demo-brand">VR Web Player Tests</h1>
<p class="demo-kicker">Open a focused page for each media and projection combination.</p>
</div>
</header>
<nav class="demo-grid" aria-label="Player test pages">
<a class="demo-card" href="./test-3d-image.html">
<h2>3D Image</h2>
<p>Flat SBS image on a rectangular plane.</p>
<span class="demo-meta">image / plane</span>
</a>
<a class="demo-card" href="./test-vr180-3d-image.html">
<h2>VR180 3D Image</h2>
<p>SBS image on the VR180 hemisphere.</p>
<span class="demo-meta">image / vr180</span>
</a>
<a class="demo-card" href="./test-3d-image-carousel.html">
<h2>3D Image Carousel</h2>
<p>Flat SBS image carousel with previous and next controls.</p>
<span class="demo-meta">image carousel / plane</span>
</a>
<a class="demo-card" href="./test-vr180-3d-image-carousel.html">
<h2>VR180 Image Carousel</h2>
<p>VR180 SBS image carousel in one immersive session.</p>
<span class="demo-meta">image carousel / vr180</span>
</a>
<a class="demo-card" href="./test-3d-video.html">
<h2>3D Video</h2>
<p>Flat SBS video on a rectangular plane.</p>
<span class="demo-meta">video / plane</span>
</a>
<a class="demo-card" href="./test-vr180-3d-video.html">
<h2>VR180 3D Video</h2>
<p>SBS video on the VR180 hemisphere.</p>
<span class="demo-meta">video / vr180</span>
</a>
</nav>
<p class="demo-note">Image tests use files in <code>../media/</code>. Video tests expect <code>../media/sbs-video.mp4</code>.</p>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>3D Image Carousel Test</title>
<link rel="stylesheet" href="./demo.css">
<link rel="stylesheet" href="../vr180player/vr180-player.css" data-vr-web-player-stylesheet>
</head>
<body>
<main class="demo-page">
<div class="demo-shell demo-player">
<header class="demo-topbar">
<div>
<h1 class="demo-brand">3D Image Carousel</h1>
<p class="demo-kicker">Projection: plane. Media: multiple side-by-side images.</p>
</div>
<a class="demo-back" href="./index.html">Back</a>
</header>
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
<div class="demo-player-frame" data-vr-web-player data-projection="plane" data-carousel>
<img src="../media/169_3d_test.png" alt="Demo SBS image one" title="3D Image Plane 1" crossorigin="anonymous">
<img src="../media/169_3d_test.png?slide=2" alt="Demo SBS image two" title="3D Image Plane 2" crossorigin="anonymous">
</div>
</div>
</main>
<script type="module" src="./demo-xr-status.js"></script>
<script type="module" src="../vr180player/vr180-player.js"></script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>3D Image Test</title>
<link rel="stylesheet" href="./demo.css">
<link rel="stylesheet" href="../vr180player/vr180-player.css" data-vr-web-player-stylesheet>
</head>
<body>
<main class="demo-page">
<div class="demo-shell demo-player">
<header class="demo-topbar">
<div>
<h1 class="demo-brand">3D Image</h1>
<p class="demo-kicker">Projection: plane. Media: side-by-side image.</p>
</div>
<a class="demo-back" href="./index.html">Back</a>
</header>
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
<div class="demo-player-frame" data-vr-web-player data-projection="plane">
<img src="../media/169_3d_test.png" alt="Demo SBS image" title="3D Image Plane" crossorigin="anonymous">
</div>
</div>
</main>
<script type="module" src="./demo-xr-status.js"></script>
<script type="module" src="../vr180player/vr180-player.js"></script>
</body>
</html>

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>3D Video Test</title>
<link rel="stylesheet" href="./demo.css">
<link rel="stylesheet" href="../vr180player/vr180-player.css" data-vr-web-player-stylesheet>
</head>
<body>
<main class="demo-page">
<div class="demo-shell demo-player">
<header class="demo-topbar">
<div>
<h1 class="demo-brand">3D Video</h1>
<p class="demo-kicker">Projection: plane. Media: side-by-side video.</p>
</div>
<a class="demo-back" href="./index.html">Back</a>
</header>
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
<div class="demo-player-frame" data-vr-web-player data-projection="plane">
<video poster="../poster.jpg" title="3D Video Plane" crossorigin="anonymous" playsinline preload="metadata">
<source src="../media/sbs-video.mp4" type="video/mp4">
</video>
</div>
</div>
</main>
<script type="module" src="./demo-xr-status.js"></script>
<script type="module" src="../vr180player/vr180-player.js"></script>
</body>
</html>

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>VR180 3D Image Carousel Test</title>
<link rel="stylesheet" href="./demo.css">
<link rel="stylesheet" href="../vr180player/vr180-player.css" data-vr-web-player-stylesheet>
</head>
<body>
<main class="demo-page">
<div class="demo-shell demo-player">
<header class="demo-topbar">
<div>
<h1 class="demo-brand">VR180 Image Carousel</h1>
<p class="demo-kicker">Projection: VR180. Media: multiple side-by-side images.</p>
</div>
<a class="demo-back" href="./index.html">Back</a>
</header>
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
<div class="demo-player-frame" data-vr-web-player data-projection="vr180" data-carousel>
<img src="../media/VR180_SBS_Test.png" alt="Demo VR180 SBS image one" title="VR180 3D Image 1" crossorigin="anonymous">
<img src="../media/VR180_SBS_Test.png?slide=2" alt="Demo VR180 SBS image two" title="VR180 3D Image 2" crossorigin="anonymous">
</div>
</div>
</main>
<script type="module" src="./demo-xr-status.js"></script>
<script type="module" src="../vr180player/vr180-player.js"></script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>VR180 3D Image Test</title>
<link rel="stylesheet" href="./demo.css">
<link rel="stylesheet" href="../vr180player/vr180-player.css" data-vr-web-player-stylesheet>
</head>
<body>
<main class="demo-page">
<div class="demo-shell demo-player">
<header class="demo-topbar">
<div>
<h1 class="demo-brand">VR180 3D Image</h1>
<p class="demo-kicker">Projection: VR180. Media: side-by-side image.</p>
</div>
<a class="demo-back" href="./index.html">Back</a>
</header>
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
<div class="demo-player-frame" data-vr-web-player data-projection="vr180">
<img src="../media/VR180_SBS_Test.png" alt="Demo VR180 SBS image" title="VR180 3D Image" crossorigin="anonymous">
</div>
</div>
</main>
<script type="module" src="./demo-xr-status.js"></script>
<script type="module" src="../vr180player/vr180-player.js"></script>
</body>
</html>

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>VR180 3D Video Test</title>
<link rel="stylesheet" href="./demo.css">
<link rel="stylesheet" href="../vr180player/vr180-player.css" data-vr-web-player-stylesheet>
</head>
<body>
<main class="demo-page">
<div class="demo-shell demo-player">
<header class="demo-topbar">
<div>
<h1 class="demo-brand">VR180 3D Video</h1>
<p class="demo-kicker">Projection: VR180. Media: side-by-side video.</p>
</div>
<a class="demo-back" href="./index.html">Back</a>
</header>
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
<div class="demo-player-frame" data-vr-web-player data-projection="vr180">
<video poster="../poster.jpg" title="VR180 3D Video" crossorigin="anonymous" playsinline preload="metadata">
<source src="../media/Test.mp4" type="video/mp4">
</video>
</div>
</div>
</main>
<script type="module" src="./demo-xr-status.js"></script>
<script type="module" src="../vr180player/vr180-player.js"></script>
</body>
</html>

101
tests/hand-aim.test.mjs Normal file
View File

@@ -0,0 +1,101 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
beginPalmAimSelection,
computePalmAimRay,
createPalmAimLatch,
endPalmAimSelection,
getPalmAimSelectionRay,
recordStablePalmAimRay
} from '../vr180player/xr/hand-aim.js';
const wrist = { x: 0, y: 0, z: 0 };
const middleMetacarpal = { x: 0, y: 0.1, z: 0 };
const ringMetacarpal = { x: 0.025, y: 0.1, z: 0 };
const straightAheadRay = {
direction: { x: 0, y: 0, z: -1 },
origin: { x: 0, y: 1.4, z: -0.04 }
};
const pinchedRay = {
direction: { x: 0.4, y: -0.2, z: -0.8 },
origin: { x: 0.04, y: 1.32, z: -0.03 }
};
test('computePalmAimRay tilts a right palm ray toward the finger-forward axis', () => {
const ray = computePalmAimRay({
handedness: 'right',
indexMetacarpal: { x: -0.045, y: 0.1, z: 0 },
middleMetacarpal: { x: -0.015, y: 0.1, z: 0 },
pinkyMetacarpal: { x: 0.045, y: 0.1, z: 0 },
ringMetacarpal: { x: 0.015, y: 0.1, z: 0 },
wrist
});
assert.ok(ray);
assert.equal(Math.round(ray.direction.x * 1000) / 1000, 0);
assert.equal(Math.round(ray.direction.y * 1000) / 1000, 0.643);
assert.equal(Math.round(ray.direction.z * 1000) / 1000, -0.766);
assert.equal(Math.round(ray.origin.y * 1000) / 1000, 0.084);
assert.equal(Math.round(ray.origin.z * 1000) / 1000, -0.027);
});
test('computePalmAimRay flips the palm normal for a left hand while keeping forward tilt', () => {
const ray = computePalmAimRay({
handedness: 'left',
indexMetacarpal: { x: 0.045, y: 0.1, z: 0 },
middleMetacarpal: { x: 0.015, y: 0.1, z: 0 },
pinkyMetacarpal: { x: -0.045, y: 0.1, z: 0 },
ringMetacarpal: { x: -0.015, y: 0.1, z: 0 },
wrist
});
assert.ok(ray);
assert.equal(Math.round(ray.direction.y * 1000) / 1000, 0.643);
assert.equal(Math.round(ray.direction.z * 1000) / 1000, -0.766);
});
test('computePalmAimRay returns null when palm joints cannot define a stable ray', () => {
assert.equal(computePalmAimRay({ handedness: 'right', wrist }), null);
assert.equal(computePalmAimRay({
handedness: 'right',
indexMetacarpal: { x: 0, y: 0.1, z: 0 },
pinkyMetacarpal: { x: 0, y: 0.1, z: 0 },
wrist
}), null);
});
test('palm aim latch returns the latest fresh stable ray', () => {
const latch = createPalmAimLatch();
recordStablePalmAimRay(latch, straightAheadRay, 100);
assert.deepEqual(getPalmAimSelectionRay(latch, 120), straightAheadRay);
assert.equal(getPalmAimSelectionRay(latch, 500), null);
});
test('palm aim latch freezes the pre-selection ray while selecting', () => {
const latch = createPalmAimLatch();
recordStablePalmAimRay(latch, straightAheadRay, 100);
assert.deepEqual(beginPalmAimSelection(latch, 120), straightAheadRay);
recordStablePalmAimRay(latch, pinchedRay, 130);
assert.deepEqual(getPalmAimSelectionRay(latch, 140), straightAheadRay);
assert.deepEqual(getPalmAimSelectionRay(latch, 1000), straightAheadRay);
endPalmAimSelection(latch);
recordStablePalmAimRay(latch, pinchedRay, 150);
assert.deepEqual(getPalmAimSelectionRay(latch, 160), pinchedRay);
});
test('palm aim latch ignores stale rays when selection starts too late', () => {
const latch = createPalmAimLatch();
recordStablePalmAimRay(latch, straightAheadRay, 100);
assert.equal(beginPalmAimSelection(latch, 450), null);
assert.equal(getPalmAimSelectionRay(latch, 450), null);
});

71
tests/input-mode.test.mjs Normal file
View File

@@ -0,0 +1,71 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getPointerInputMode,
rememberPointerInputMode,
shouldUseHandPointer
} from '../vr180player/xr/input-mode.js';
test('getPointerInputMode detects WebXR hand sources', () => {
assert.equal(getPointerInputMode({ hand: {} }), 'hand');
assert.equal(getPointerInputMode({ profiles: ['generic-hand-tracking'] }), 'hand');
assert.equal(getPointerInputMode({ profiles: ['Oculus-Hand'] }), 'hand');
});
test('getPointerInputMode detects controller sources', () => {
assert.equal(getPointerInputMode({ gamepad: {} }), 'controller');
assert.equal(getPointerInputMode({ targetRayMode: 'tracked-pointer' }), 'controller');
});
test('getPointerInputMode returns null for unknown or gaze-like sources', () => {
assert.equal(getPointerInputMode(null), null);
assert.equal(getPointerInputMode(undefined), null);
assert.equal(getPointerInputMode({ profiles: ['generic-trigger'] }), null);
assert.equal(getPointerInputMode({ targetRayMode: 'gaze' }), null);
});
test('rememberPointerInputMode reads input sources from supported event shapes', () => {
const fromNestedInputSource = {};
rememberPointerInputMode(fromNestedInputSource, { data: { inputSource: { hand: {} } } }, 'controller');
assert.equal(fromNestedInputSource.pointerInputMode, 'hand');
const fromDirectInputSource = {};
rememberPointerInputMode(fromDirectInputSource, { inputSource: { gamepad: {} } }, 'hand');
assert.equal(fromDirectInputSource.pointerInputMode, 'controller');
const fromDataSource = {};
rememberPointerInputMode(fromDataSource, { data: { targetRayMode: 'tracked-pointer' } }, 'hand');
assert.equal(fromDataSource.pointerInputMode, 'controller');
});
test('rememberPointerInputMode keeps fallback mode when the event is ambiguous', () => {
const inputSource = { pointerInputMode: 'hand' };
rememberPointerInputMode(inputSource, { data: { targetRayMode: 'gaze' } }, 'controller');
assert.equal(inputSource.pointerInputMode, 'controller');
});
test('rememberPointerInputMode stores the input source on controller userData', () => {
const inputSource = {
controller: {
userData: {
existing: true
}
}
};
rememberPointerInputMode(inputSource, { data: { inputSource: { hand: {} } } }, 'controller');
assert.equal(inputSource.pointerInputMode, 'hand');
assert.equal(inputSource.controller.userData.existing, true);
assert.equal(inputSource.controller.userData.vrwpInputSource, inputSource);
});
test('shouldUseHandPointer only enables the hand ray for remembered hand mode', () => {
assert.equal(shouldUseHandPointer({ pointerInputMode: 'hand' }), true);
assert.equal(shouldUseHandPointer({ pointerInputMode: 'controller' }), false);
assert.equal(shouldUseHandPointer({}), false);
assert.equal(shouldUseHandPointer(undefined), false);
});

View File

@@ -0,0 +1,302 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createMediaAdapter,
ImageCarouselMediaAdapter,
ImageMediaAdapter,
VideoMediaAdapter
} from '../vr180player/media/media-adapter.js';
function createClassList() {
return {
values: [],
add(...values) {
this.values.push(...values);
}
};
}
function createVideo({
ended = false,
paused = false,
source = 'https://cdn.example.com/videos/demo-video.mp4',
title = ''
} = {}) {
return {
HAVE_METADATA: 1,
classList: createClassList(),
ended,
loadCount: 0,
paused,
readyState: 0,
style: { display: '' },
tagName: 'VIDEO',
addEventListener() {},
getAttribute(name) {
return name === 'title' ? title : '';
},
load() {
this.loadCount += 1;
},
querySelector(selector) {
if (selector !== 'source' || !source) {
return null;
}
return { src: source };
}
};
}
function createImage({
alt = '',
complete = true,
naturalWidth = 1920,
source = 'https://cdn.example.com/images/demo-image.png',
title = ''
} = {}) {
return {
alt,
classList: createClassList(),
complete,
currentSrc: source,
listeners: {},
naturalWidth,
src: source,
style: { display: '' },
tagName: 'IMG',
addEventListener(type, listener) {
this.listeners[type] ??= [];
this.listeners[type].push(listener);
},
dispatch(type) {
for (const listener of this.listeners[type] ?? []) {
listener({ currentTarget: this });
}
},
getAttribute(name) {
if (name === 'title') return title;
if (name === 'alt') return alt;
return '';
}
};
}
test('VideoMediaAdapter exposes video capabilities and lifecycle helpers', () => {
const video = createVideo({ title: 'Demo Title' });
const adapter = new VideoMediaAdapter(video);
assert.deepEqual(adapter.capabilities, {
audio: true,
carousel: false,
dynamicTexture: true,
navigation: true,
playback: true,
timeline: true
});
assert.equal(adapter.element, video);
assert.equal(adapter.textureSource, video);
assert.equal(adapter.getTitle(), 'Demo Title');
adapter.hideElement();
assert.equal(video.style.display, 'none');
adapter.showElement();
assert.equal(video.style.display, '');
adapter.load();
assert.equal(video.loadCount, 1);
});
test('VideoMediaAdapter falls back to source filename and tracks texture update state', () => {
const video = createVideo({ source: 'https://cdn.example.com/media/flat-sbs-demo.mp4' });
const adapter = new VideoMediaAdapter(video);
assert.equal(adapter.getTitle(), 'flat sbs demo');
assert.equal(adapter.shouldUpdateTexture(), true);
video.paused = true;
assert.equal(adapter.shouldUpdateTexture(), false);
video.paused = false;
video.ended = true;
assert.equal(adapter.shouldUpdateTexture(), false);
});
test('ImageMediaAdapter exposes static image capabilities and lifecycle helpers', async () => {
const image = createImage({ alt: 'Alt Title' });
const adapter = new ImageMediaAdapter(image);
let readyCount = 0;
adapter.bindLoadState({
onError: () => {},
onReady: () => {
readyCount += 1;
}
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(adapter.capabilities, {
audio: false,
carousel: false,
dynamicTexture: false,
navigation: false,
playback: false,
timeline: false
});
assert.equal(adapter.element, image);
assert.equal(adapter.textureSource, image);
assert.equal(adapter.getTitle(), 'Alt Title');
assert.equal(adapter.shouldUpdateTexture(), false);
assert.equal(readyCount, 1);
adapter.hideElement();
assert.equal(image.style.display, 'none');
adapter.showElement();
assert.equal(image.style.display, '');
});
test('ImageMediaAdapter falls back to source filename', () => {
const image = createImage({ alt: '', source: 'https://cdn.example.com/media/static-sbs-demo.png' });
const adapter = new ImageMediaAdapter(image);
assert.equal(adapter.getTitle(), 'static sbs demo');
});
test('ImageCarouselMediaAdapter exposes carousel image navigation', () => {
const firstImage = createImage({ title: 'First image', source: 'https://cdn.example.com/media/first.png' });
const secondImage = createImage({ title: 'Second image', source: 'https://cdn.example.com/media/second.png' });
const adapter = new ImageCarouselMediaAdapter([firstImage, secondImage]);
assert.deepEqual(adapter.capabilities, {
audio: false,
carousel: true,
dynamicTexture: false,
navigation: true,
playback: false,
timeline: false
});
assert.equal(adapter.element, firstImage);
assert.equal(adapter.textureSource, firstImage);
assert.equal(adapter.getTitle(), 'First image');
assert.equal(firstImage.style.display, '');
assert.equal(secondImage.style.display, 'none');
assert.deepEqual(firstImage.classList.values, ['vrwp-media', 'vrwp-image', 'vrwp-carousel-image']);
assert.deepEqual(secondImage.classList.values, ['vrwp-media', 'vrwp-image', 'vrwp-carousel-image']);
assert.equal(adapter.next(), true);
assert.equal(adapter.element, secondImage);
assert.equal(adapter.textureSource, secondImage);
assert.equal(adapter.getTitle(), 'Second image');
assert.equal(firstImage.style.display, 'none');
assert.equal(secondImage.style.display, '');
assert.equal(adapter.next(), true);
assert.equal(adapter.element, firstImage);
adapter.hideElement();
assert.equal(firstImage.style.display, 'none');
assert.equal(secondImage.style.display, 'none');
adapter.previous();
adapter.showElement();
assert.equal(adapter.element, secondImage);
assert.equal(firstImage.style.display, 'none');
assert.equal(secondImage.style.display, '');
adapter.load();
assert.equal(firstImage.loading, 'eager');
assert.equal(secondImage.loading, 'eager');
});
test('ImageCarouselMediaAdapter waits for all images before reporting ready', async () => {
const firstImage = createImage({ complete: false, naturalWidth: 0 });
const secondImage = createImage({ complete: false, naturalWidth: 0 });
const adapter = new ImageCarouselMediaAdapter([firstImage, secondImage]);
let readyCount = 0;
adapter.bindLoadState({
onError: () => {},
onReady: () => {
readyCount += 1;
}
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(readyCount, 0);
firstImage.complete = true;
firstImage.naturalWidth = 1920;
firstImage.dispatch('load');
assert.equal(readyCount, 0);
secondImage.complete = true;
secondImage.naturalWidth = 1920;
secondImage.dispatch('load');
assert.equal(readyCount, 1);
firstImage.dispatch('load');
assert.equal(readyCount, 1);
});
test('createMediaAdapter finds and marks the supported video element', () => {
const video = createVideo();
const playerContainer = {
querySelectorAll(selector) {
return selector === 'video,img' ? [video] : [];
}
};
const adapter = createMediaAdapter(playerContainer);
assert.ok(adapter instanceof VideoMediaAdapter);
assert.equal(adapter.element, video);
assert.deepEqual(video.classList.values, ['vrwp-media', 'vrwp-video']);
});
test('createMediaAdapter finds and marks the supported image element', () => {
const image = createImage();
const playerContainer = {
querySelectorAll(selector) {
return selector === 'video,img' ? [image] : [];
}
};
const adapter = createMediaAdapter(playerContainer);
assert.ok(adapter instanceof ImageMediaAdapter);
assert.equal(adapter.element, image);
assert.deepEqual(image.classList.values, ['vrwp-media', 'vrwp-image']);
});
test('createMediaAdapter creates an image carousel when requested', () => {
const firstImage = createImage({ title: 'First image' });
const secondImage = createImage({ title: 'Second image' });
const playerContainer = {
dataset: { carousel: '' },
querySelectorAll(selector) {
return selector === 'video,img' ? [firstImage, secondImage] : [];
}
};
const adapter = createMediaAdapter(playerContainer);
assert.ok(adapter instanceof ImageCarouselMediaAdapter);
assert.equal(adapter.element, firstImage);
assert.equal(adapter.next(), true);
assert.equal(adapter.element, secondImage);
});
test('createMediaAdapter refuses missing or ambiguous media elements', () => {
const video = createVideo();
const image = createImage();
const secondImage = createImage();
assert.equal(createMediaAdapter({ querySelectorAll: () => [] }), null);
assert.equal(createMediaAdapter({ querySelectorAll: () => [video, image] }), null);
assert.equal(createMediaAdapter({ querySelectorAll: () => [image, secondImage] }), null);
assert.equal(createMediaAdapter({ dataset: { carousel: '' }, querySelectorAll: () => [image] }), null);
assert.equal(createMediaAdapter({ dataset: { carousel: '' }, querySelectorAll: () => [video, image] }), null);
});

View File

@@ -0,0 +1,209 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MediaController } from '../vr180player/media/media-controller.js';
function createClassList() {
const classes = new Set();
return {
add: (className) => classes.add(className),
contains: (className) => classes.has(className),
remove: (className) => classes.delete(className)
};
}
function createVideo(overrides = {}) {
const video = {
HAVE_ENOUGH_DATA: 4,
controls: true,
currentSrc: 'video.mp4',
currentTime: 20,
duration: 120,
ended: false,
loop: false,
loadCount: 0,
muted: false,
pauseCount: 0,
paused: true,
playCount: 0,
readyState: 4,
volume: 1,
load() {
this.loadCount += 1;
},
pause() {
this.pauseCount += 1;
this.paused = true;
},
play() {
this.playCount += 1;
this.paused = false;
this.ended = false;
return Promise.resolve();
},
...overrides
};
return video;
}
function createController({
is2DModeActive = () => false,
on2DPlaybackResume = () => {},
playButton = { classList: createClassList(), disabled: true },
video = createVideo()
} = {}) {
return {
controller: new MediaController({
is2DModeActive,
on2DPlaybackResume,
playButton,
video
}),
playButton,
video
};
}
test('MediaController clamps skip controls to the video bounds', () => {
const { controller, video } = createController();
controller.forward();
assert.equal(video.currentTime, 35);
video.currentTime = 118;
controller.forward();
assert.equal(video.currentTime, 120);
controller.rewind();
assert.equal(video.currentTime, 105);
video.currentTime = 4;
controller.rewind();
assert.equal(video.currentTime, 0);
});
test('MediaController seeks by progress and ignores videos without finite duration', () => {
const { controller, video } = createController();
controller.seekToProgress(0.25);
assert.equal(video.currentTime, 30);
video.duration = Number.POSITIVE_INFINITY;
controller.seekToProgress(0.75);
assert.equal(video.currentTime, 30);
});
test('MediaController toggles mute and native controls', () => {
const { controller, video } = createController();
controller.toggleMute();
assert.equal(video.muted, true);
controller.toggleMute();
assert.equal(video.muted, false);
video.controls = false;
controller.enableNativeControls();
assert.equal(video.controls, true);
});
test('MediaController toggles loop playback state', () => {
const { controller, video } = createController();
assert.equal(controller.isLooping(), false);
assert.equal(controller.toggleLoop(), true);
assert.equal(video.loop, true);
assert.equal(controller.isLooping(), true);
assert.equal(controller.toggleLoop(), false);
assert.equal(video.loop, false);
});
test('MediaController resets video and play button to poster state', () => {
const playButton = { classList: createClassList(), disabled: true };
playButton.classList.add('hidden');
const { controller, video } = createController({ playButton });
controller.resetToOriginalState();
assert.equal(video.pauseCount, 1);
assert.equal(video.currentTime, 0);
assert.equal(video.controls, false);
assert.equal(video.loadCount, 1);
assert.equal(playButton.classList.contains('hidden'), false);
assert.equal(playButton.disabled, false);
});
test('MediaController restarts ended video before playing again', async () => {
let resumed = false;
const { controller, video } = createController({
is2DModeActive: () => true,
on2DPlaybackResume: () => {
resumed = true;
},
video: createVideo({ currentTime: 120, ended: true, paused: true })
});
controller.togglePlayPause();
await Promise.resolve();
assert.equal(video.currentTime, 0);
assert.equal(video.playCount, 1);
assert.equal(resumed, true);
const vrVideo = createVideo({ currentTime: 120, ended: true, paused: true });
const { controller: vrController } = createController({ video: vrVideo });
vrController.togglePlayPause();
await Promise.resolve();
assert.equal(vrVideo.currentTime, 0);
assert.equal(vrVideo.playCount, 1);
});
test('MediaController pauses when toggling playback while already playing', () => {
const { controller, video } = createController({
video: createVideo({ paused: false })
});
controller.togglePlayPause();
assert.equal(video.pauseCount, 1);
assert.equal(video.paused, true);
});
test('MediaController dispatches ended behavior for VR, 2D, and idle modes', () => {
const vrCalls = [];
const { controller } = createController({
video: createVideo({ paused: false })
});
controller.handleEnded({
isIn2DMode: () => false,
isInVr: () => true,
on2DEnded: () => vrCalls.push('2d'),
onVrEnded: () => vrCalls.push('vr'),
resetToOriginalState: () => vrCalls.push('reset')
});
assert.deepEqual(vrCalls, ['vr']);
const twoDCalls = [];
controller.handleEnded({
isIn2DMode: () => true,
isInVr: () => false,
on2DEnded: () => twoDCalls.push('2d'),
onVrEnded: () => twoDCalls.push('vr'),
resetToOriginalState: () => twoDCalls.push('reset')
});
assert.deepEqual(twoDCalls, ['2d']);
const idleCalls = [];
controller.handleEnded({
isIn2DMode: () => false,
isInVr: () => false,
on2DEnded: () => idleCalls.push('2d'),
onVrEnded: () => idleCalls.push('vr'),
resetToOriginalState: () => idleCalls.push('reset')
});
assert.deepEqual(idleCalls, ['reset']);
});

161
tests/projection.test.mjs Normal file
View File

@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyHeadPositionLock,
applySbsTextureWindow,
hideContentMeshes,
isLeftEyeCamera,
positionPlaneForPresentation,
resetHeadPositionLockedContent,
shouldLockContentToHeadPosition,
showActiveContentMesh
} from '../vr180player/rendering/projection.js';
function createMaterial() {
return {
map: {
offset: { x: 99, y: 99 },
repeat: { x: 99, y: 99 }
}
};
}
function createRenderer({ isPresenting = false, xrCamera = null } = {}) {
return {
xr: {
getCamera: () => xrCamera,
isPresenting
}
};
}
function createCamera(x, projectionOffset = 0) {
return {
matrixWorldInverse: { elements: new Array(16).fill(0).with(12, x) },
matrixWorld: { elements: new Array(16).fill(0).with(12, x).with(13, 1.7).with(14, 0.25) },
projectionMatrix: { elements: new Array(16).fill(0).with(8, projectionOffset) }
};
}
function createPositionedMesh() {
const calls = [];
return {
calls,
position: {
set: (...args) => calls.push(args)
}
};
}
test('applySbsTextureWindow uses left eye only in non-XR 2D fallback', () => {
const material = createMaterial();
applySbsTextureWindow(createRenderer(), createCamera(0), material, true);
assert.equal(material.map.offset.x, 0);
assert.equal(material.map.repeat.x, 0.5);
assert.equal(material.map.offset.y, 0);
assert.equal(material.map.repeat.y, 1);
});
test('applySbsTextureWindow keeps the full texture outside XR when not in 2D fallback', () => {
const material = createMaterial();
applySbsTextureWindow(createRenderer(), createCamera(0), material, false);
assert.equal(material.map.offset.x, 0);
assert.equal(material.map.repeat.x, 1);
assert.equal(material.map.offset.y, 0);
assert.equal(material.map.repeat.y, 1);
});
test('applySbsTextureWindow selects the right SBS half for the right XR eye', () => {
const leftCamera = createCamera(-0.03);
const rightCamera = createCamera(0.03);
const renderer = createRenderer({
isPresenting: true,
xrCamera: { cameras: [leftCamera, rightCamera] }
});
const material = createMaterial();
applySbsTextureWindow(renderer, rightCamera, material, false);
assert.equal(material.map.offset.x, 0.5);
assert.equal(material.map.repeat.x, 0.5);
});
test('isLeftEyeCamera falls back to projection matrix offset when XR cameras are unavailable', () => {
assert.equal(isLeftEyeCamera(createRenderer({ isPresenting: true }), createCamera(0, -0.1)), true);
assert.equal(isLeftEyeCamera(createRenderer({ isPresenting: true }), createCamera(0, 0.1)), false);
});
test('showActiveContentMesh hides inactive meshes before showing the active mesh', () => {
const vr180Mesh = { visible: true };
const planeMesh = { visible: true };
showActiveContentMesh(vr180Mesh, planeMesh, planeMesh);
assert.equal(vr180Mesh.visible, false);
assert.equal(planeMesh.visible, true);
hideContentMeshes(vr180Mesh, planeMesh);
assert.equal(vr180Mesh.visible, false);
assert.equal(planeMesh.visible, false);
});
test('positionPlaneForPresentation uses the fallback camera depth in 2D plane mode', () => {
const calls = [];
const planeMesh = {
position: {
set: (...args) => calls.push(args)
}
};
positionPlaneForPresentation(planeMesh, { position: { z: 0.1 } }, true, 3, 1.2);
positionPlaneForPresentation(planeMesh, { position: { z: 0.1 } }, false, 3, 1.2);
assert.deepEqual(calls[0], [0, 1.6, -1.0999999999999999]);
assert.deepEqual(calls[1], [0, 1.6, -3]);
});
test('shouldLockContentToHeadPosition defaults to VR180 only in auto mode', () => {
assert.equal(shouldLockContentToHeadPosition('auto', 'vr180'), true);
assert.equal(shouldLockContentToHeadPosition('auto', 'plane'), false);
assert.equal(shouldLockContentToHeadPosition('position', 'plane'), true);
assert.equal(shouldLockContentToHeadPosition('none', 'vr180'), false);
});
test('applyHeadPositionLock centers VR180 content on the XR camera position', () => {
const mesh = createPositionedMesh();
applyHeadPositionLock(mesh, createCamera(0.4), 'vr180', true, 3);
assert.deepEqual(mesh.calls[0], [0.4, 1.7, 0.25]);
});
test('applyHeadPositionLock keeps opt-in plane content in front of the XR camera position', () => {
const mesh = createPositionedMesh();
applyHeadPositionLock(mesh, createCamera(-0.25), 'plane', true, 3);
assert.deepEqual(mesh.calls[0], [-0.25, 1.7, -2.75]);
});
test('applyHeadPositionLock leaves content untouched when disabled', () => {
const mesh = createPositionedMesh();
applyHeadPositionLock(mesh, createCamera(0.4), 'vr180', false, 3);
assert.deepEqual(mesh.calls, []);
});
test('resetHeadPositionLockedContent restores default mesh positions', () => {
const vr180Mesh = createPositionedMesh();
const planeMesh = createPositionedMesh();
resetHeadPositionLockedContent(vr180Mesh, planeMesh, 3);
assert.deepEqual(vr180Mesh.calls[0], [0, 0, 0]);
assert.deepEqual(planeMesh.calls[0], [0, 1.6, -3]);
});

View File

@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MediaTextureManager } from '../vr180player/rendering/texture-manager.js';
function createTexture(name) {
return {
disposed: false,
name,
needsUpdate: false,
dispose() {
this.disposed = true;
}
};
}
function createVideo({ paused = false, ended = false } = {}) {
return { ended, paused };
}
test('MediaTextureManager replaces the previous texture when creating a new one', () => {
const created = [];
const manager = new MediaTextureManager(createVideo(), () => {
const texture = createTexture(`texture-${created.length}`);
created.push(texture);
return texture;
}, () => true);
const first = manager.create();
const second = manager.create();
assert.equal(first.disposed, true);
assert.equal(second.disposed, false);
assert.equal(manager.current, second);
assert.equal(created.length, 2);
});
test('MediaTextureManager assigns and clears material maps', () => {
const material = { map: null, needsUpdate: false };
const manager = new MediaTextureManager(createVideo(), () => createTexture('assigned'), () => true);
const texture = manager.assignToMaterial(material);
assert.equal(material.map, texture);
assert.equal(material.needsUpdate, true);
assert.equal(manager.current, texture);
material.needsUpdate = false;
manager.clearMaterial(material);
assert.equal(texture.disposed, true);
assert.equal(material.map, null);
assert.equal(material.needsUpdate, true);
assert.equal(manager.current, null);
});
test('MediaTextureManager can switch sources before creating the next texture', () => {
const firstSource = { name: 'first' };
const secondSource = { name: 'second' };
const createdFrom = [];
const manager = new MediaTextureManager(firstSource, (source) => {
createdFrom.push(source);
return createTexture(source.name);
}, () => true);
const firstTexture = manager.create();
manager.setSource(secondSource);
const secondTexture = manager.create();
assert.equal(firstTexture.disposed, true);
assert.equal(secondTexture.name, 'second');
assert.deepEqual(createdFrom, [firstSource, secondSource]);
});
test('MediaTextureManager only marks textures dirty when the update predicate allows it', () => {
const video = createVideo();
const manager = new MediaTextureManager(
video,
() => createTexture('playing'),
() => !video.paused && !video.ended
);
const texture = manager.create();
manager.updateIfNeeded();
assert.equal(texture.needsUpdate, true);
texture.needsUpdate = false;
video.paused = true;
manager.updateIfNeeded();
assert.equal(texture.needsUpdate, false);
video.paused = false;
video.ended = true;
manager.updateIfNeeded();
assert.equal(texture.needsUpdate, false);
});

20
tests/time.test.mjs Normal file
View File

@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatTime } from '../vr180player/utils/time.js';
test('formatTime formats minute-length durations', () => {
assert.equal(formatTime(0), '00:00');
assert.equal(formatTime(65), '01:05');
assert.equal(formatTime(599), '09:59');
});
test('formatTime formats hour-length durations', () => {
assert.equal(formatTime(3600), '01:00:00');
assert.equal(formatTime(3661), '01:01:01');
});
test('formatTime handles invalid durations defensively', () => {
assert.equal(formatTime(Number.NaN), '00:00:00');
assert.equal(formatTime(Number.POSITIVE_INFINITY), '00:00:00');
});

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"rootDir": "src/vr180player",
"outDir": "vr180player",
"allowJs": false,
"checkJs": false,
"strict": false,
"noEmitOnError": true,
"skipLibCheck": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"]
},
"include": ["src/vr180player/**/*.ts", "src/vr180player/**/*.d.ts"]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 796 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 666 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1014 B

View File

@@ -1,16 +1,23 @@
#vr-container {
.vrwp {
position: relative;
display: inline-block;
width: 100%;
}
video {
width: 100%;
height: auto;
aspect-ratio: 16/9;
.vrwp [hidden] {
display: none !important;
}
#playBtn {
.vrwp-media,
.vrwp canvas {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
display: block;
object-fit: contain;
}
.vrwp-play-button {
position: absolute;
top: 50%;
left: 50%;
@@ -25,41 +32,40 @@ video {
z-index: 10;
}
#playBtn:hover {
.vrwp-play-button:hover {
transform: translate(-50%, -50%) scale(1.1);
}
#playBtn:active {
.vrwp-play-button:active {
transform: translate(-50%, -50%) scale(0.95);
}
#playBtn.hidden {
.vrwp-play-button.hidden {
opacity: 0;
pointer-events: none;
}
#playBtn img {
.vrwp-play-button .vrwp-icon {
width: 100%;
height: 100%;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.45));
}
/* Responsive sizing */
@media (max-width: 600px) {
#playBtn {
.vrwp-play-button {
width: 60px;
height: 60px;
}
}
@media (min-width: 900px) {
#playBtn {
.vrwp-play-button {
width: 100px;
height: 100px;
}
}
/* 2D Video Controls Panel */
#panel {
.vrwp-panel {
position: absolute;
bottom: 10%;
left: 50%;
@@ -69,7 +75,7 @@ video {
border-radius: 30px;
background: rgba(0, 0, 0, 0.70);
color: #fff;
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
z-index: 100;
opacity: 0;
visibility: hidden;
@@ -77,38 +83,38 @@ video {
pointer-events: none;
}
#panel.visible {
.vrwp-panel.visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
#status {
.vrwp-status {
margin: 0 12px 12px 12px;
}
#video-title {
.vrwp-video-title {
text-align: center;
margin: 0 0 16px 0;
font-size: 1rem;
font-weight: 500;
}
#current-time,
#total-time {
.vrwp-current-time,
.vrwp-total-time {
margin: 0;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
}
#progress {
.vrwp-progress {
display: grid;
grid-template-columns: min-content 1fr min-content;
grid-gap: 8px;
align-items: center;
}
#bar {
.vrwp-bar {
width: 100%;
height: 4px;
border-radius: 2px;
@@ -117,7 +123,7 @@ video {
position: relative;
}
#played {
.vrwp-played {
border-radius: 2px;
background: #fff;
height: 4px;
@@ -125,66 +131,63 @@ video {
transition: width 0.1s ease;
}
#controls {
.vrwp-controls {
display: grid;
grid-template-areas: "full lflex nav rflex mute";
grid-template-columns: 44px 1fr 156px 1fr 44px;
grid-template-areas: "full lflex nav rflex loop mute";
grid-template-columns: 44px 1fr 156px 1fr 44px 44px;
column-gap: 8px;
height: 44px;
}
#panel button {
.vrwp-panel button {
cursor: pointer;
border: none;
background-color: transparent;
color: #fff;
display: grid;
place-items: center;
padding: 0;
position: relative;
transition: color 0.15s ease-in-out;
}
#fullscreen {
.vrwp-panel button:hover {
color: #d8d8d8;
}
.vrwp-icon {
width: 28px;
height: 28px;
stroke: currentColor;
}
.vrwp-fullscreen,
.vrwp-loop,
.vrwp-mute,
.vrwp-back,
.vrwp-play-toggle,
.vrwp-forward {
width: 44px;
height: 44px;
}
.vrwp-fullscreen {
grid-area: full;
width: 44px;
height: 44px;
background-image: url(images/fullscreen.png);
background-size: 44px 44px;
background-repeat: no-repeat;
background-position: center;
transition: background-image 0.15s ease-in-out;
}
#fullscreen:hover {
background-image: url(images/fullscreen-hover.png);
}
#mute {
.vrwp-mute {
grid-area: mute;
width: 44px;
height: 44px;
background-image: url(images/mute.png);
background-size: 44px 44px;
background-repeat: no-repeat;
background-position: center;
transition: background-image 0.15s ease-in-out;
}
#mute:hover {
background-image: url(images/mute-hover.png);
.vrwp-loop {
grid-area: loop;
}
#mute.muted {
background-image: url(images/mute.png);
.vrwp-loop.active {
color: #7dd3fc;
}
#mute.muted:hover {
background-image: url(images/mute-hover.png);
}
#mute.unmuted {
background-image: url(images/unmute.png);
}
#mute.unmuted:hover {
background-image: url(images/unmute-hover.png);
}
#nav {
.vrwp-nav {
grid-area: nav;
display: grid;
grid-template-columns: 44px 44px 44px;
@@ -192,60 +195,13 @@ video {
height: 44px;
}
#back {
width: 44px;
height: 44px;
background-image: url(images/back.png);
background-size: 44px 44px;
background-repeat: no-repeat;
background-position: center;
transition: background-image 0.15s ease-in-out;
}
#back:hover {
background-image: url(images/back-hover.png);
}
#play2 {
width: 44px;
height: 44px;
background-image: url(images/play2.png);
background-size: 44px 44px;
background-repeat: no-repeat;
background-position: center;
transition: background-image 0.15s ease-in-out;
}
#play2:hover {
background-image: url(images/play2-hover.png);
}
#play2.paused {
background-image: url(images/play2.png);
}
#play2.paused:hover {
background-image: url(images/play2-hover.png);
}
#play2.playing {
background-image: url(images/pause.png);
}
#play2.playing:hover {
background-image: url(images/pause-hover.png);
}
#forward {
width: 44px;
height: 44px;
background-image: url(images/forward.png);
background-size: 44px 44px;
background-repeat: no-repeat;
background-position: center;
transition: background-image 0.15s ease-in-out;
}
#forward:hover {
background-image: url(images/forward-hover.png);
.vrwp-skip-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -48%);
font-size: 0.625rem;
font-weight: 700;
line-height: 1;
pointer-events: none;
}

File diff suppressed because it is too large Load Diff