1
0

25 Commits

Author SHA1 Message Date
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
Michael Verdi
626b7f451b Create .gitattributes 2026-02-19 11:13:56 -06:00
Michael Verdi
a56e36eaf6 Merge pull request #4 from Verdi/fix/quest-stereo-v2
Fix stereo eye detection with multiple fallback methods
2026-01-26 23:39:48 -06:00
Verdi
bebcb3d355 Fix stereo eye detection with multiple fallback methods
- Replace onBeforeRender stereo detection to use cascading fallbacks:
  1. Direct xrCamera.cameras[0]/[1] comparison
  2. View matrix matrixWorldInverse.elements[12] with 0.001 threshold
  3. Projection matrix elements[8] as last resort
- Add video texture sync in renderXR before render call

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 23:27:45 -06:00
Michael Verdi
f4fb9cf6bb Merge pull request #3 from Verdi/revert-1-fix/quest-stereo-rendering
Revert "Fix stereo rendering glitches on Meta Quest browsers"
2026-01-26 23:14:47 -06:00
52 changed files with 4496 additions and 1915 deletions

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
index.html export-ignore
README.md export-ignore
sbs-video.mp4 export-ignore
poster.jpg export-ignore

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

6
.gitignore vendored Normal file
View File

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

View File

@@ -1,38 +1,65 @@
# 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.
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 video 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>
```
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 has no video, or if `data-projection` is not `vr180` or `plane`.
## Video format
This version supports 2:1 side-by-side video using H.264 or HEVC in an mp4 file. 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 video is embedded normally with a play button over the poster frame. When the user clicks play, 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 video onto the matching eyes of a 180 degree sphere.
- In WebXR, `plane` maps the left and right halves onto the matching eyes of a floating 16:9 video plane.
- Outside WebXR, both modes render only the left half of the SBS video so viewers do not see the raw double image.
- 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 this repository's `index.html` through a local web server and switch `data-projection` between `vr180` and `plane` to test both modes.
For local experimentation, run:
```sh
npm run dev
```
This builds the TypeScript player once, then serves `index.html` with Vite at a local URL.
## 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

@@ -3,31 +3,31 @@
<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">
<title>VR Web Player</title>
<link rel="stylesheet" href="./vr180player/vr180-player.css" data-vr-web-player-stylesheet>
<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;
body {
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 1rem;
font-weight: normal;
}
main {
max-width: 750px;
margin: auto;
}
</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>
<h1>VR Web Player</h1>
<p>This is a web-based player for side-by-side stereoscopic video.</p>
<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>
<!-- UI elements will be dynamically inserted here by JavaScript -->
</div>
</main>
<script type="module" src="vr180player/vr180-player.js"></script>
<script type="module" src="./vr180player/vr180-player.js"></script>
</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",
"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",
"preview": "npm run build && vite preview --host 127.0.0.1"
},
"devDependencies": {
"typescript": "^5.8.3",
"vite": "^8.0.16"
}
}

Binary file not shown.

View File

@@ -0,0 +1,99 @@
import {
DEFAULT_PROJECTION,
PLAYER_SELECTOR,
type ProjectionMode,
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 = {
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 mediaAdapter = createMediaAdapter(playerContainer);
if (!mediaAdapter) {
console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain a supported media element (video).`);
return;
}
const playButton = createPlayButton();
playerContainer.appendChild(playButton);
playerContainer.appendChild(create2DControlPanel());
playButton.disabled = true;
mediaAdapter.load();
completeXrSupportCheck(playButton, () => {
onReady({
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) {
markXrUnsupported(playButton);
onComplete();
return;
}
navigator.xr.isSessionSupported('immersive-vr').then((supported) => {
if (supported) {
playButton.dataset.xrSupported = 'true';
} else {
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;
}

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

@@ -0,0 +1,11 @@
export const PLAYER_SELECTOR = '[data-vr-web-player]';
export type ProjectionMode = 'vr180' | 'plane';
export const DEFAULT_PROJECTION: ProjectionMode = 'vr180';
export const VALID_PROJECTIONS = new Set<ProjectionMode>(['vr180', 'plane']);
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;

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

@@ -0,0 +1,111 @@
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', 'Play video');
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');
controls.appendChild(fullscreenBtn);
controls.appendChild(nav);
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,171 @@
export type LucideIconName =
| 'circle-play'
| 'play'
| 'pause'
| 'maximize'
| 'rotate-ccw'
| 'rotate-cw'
| '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' }]
],
'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' }]
],
'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,200 @@
import { setLucideIcon } from './icons.js';
import { formatTime } from '../utils/time.js';
type TwoDControlPanelCallbacks = {
onForward: () => void;
onMute: () => void;
onPlayPause: () => void;
onRewind: () => void;
onSeek: (progress: number) => void;
};
type TwoDControlPanelOptions = {
callbacks: TwoDControlPanelCallbacks;
fullscreenTarget: HTMLElement;
getIsActive: () => boolean;
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 playButton: HTMLButtonElement | null;
private muteButton: HTMLButtonElement | null;
constructor({ callbacks, fullscreenTarget, getIsActive, 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.progressBar = playerContainer.querySelector('.vrwp-bar');
this.playedBar = playerContainer.querySelector('.vrwp-played');
this.playButton = playerContainer.querySelector('.vrwp-play-toggle');
this.muteButton = playerContainer.querySelector('.vrwp-mute');
if (!this.controlPanel) {
console.error('VR_WEB_PLAYER_DOM: 2D control panel was not found.');
return;
}
if (videoTitle) {
videoTitle.textContent = title;
}
this.bindControls(playerContainer);
}
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');
}
}
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 bindControls(playerContainer: HTMLElement): void {
playerContainer.querySelector('.vrwp-fullscreen')?.addEventListener('click', () => {
this.toggleFullscreen();
});
playerContainer.querySelector('.vrwp-back')?.addEventListener('click', () => {
this.callbacks.onRewind();
this.show();
});
this.playButton?.addEventListener('click', () => {
this.callbacks.onPlayPause();
this.show();
});
playerContainer.querySelector('.vrwp-forward')?.addEventListener('click', () => {
this.callbacks.onForward();
this.show();
});
this.muteButton?.addEventListener('click', () => {
this.callbacks.onMute();
this.show();
});
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 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,70 @@
export type MediaCapabilities = {
audio: boolean;
dynamicTexture: boolean;
playback: boolean;
timeline: boolean;
};
export interface MediaAdapter<TElement extends HTMLElement = HTMLElement, TTextureSource = TElement> {
readonly capabilities: MediaCapabilities;
readonly element: TElement;
readonly kind: string;
readonly textureSource: TTextureSource;
getTitle(): string;
hideElement(): void;
load(): void;
shouldUpdateTexture(): boolean;
showElement(): void;
}
export type SupportedMediaAdapter = VideoMediaAdapter;
const VIDEO_CAPABILITIES: MediaCapabilities = {
audio: true,
dynamicTexture: true,
playback: true,
timeline: true
};
export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVideoElement> {
readonly capabilities = VIDEO_CAPABILITIES;
readonly kind = 'video';
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';
}
hideElement(): void {
this.element.style.display = 'none';
}
load(): void {
this.element.load();
}
shouldUpdateTexture(): boolean {
return !this.element.paused && !this.element.ended;
}
showElement(): void {
this.element.style.display = '';
}
}
export function createMediaAdapter(playerContainer: HTMLElement): SupportedMediaAdapter | null {
const videoElement = playerContainer.querySelector<HTMLVideoElement>('video');
if (!videoElement) {
return null;
}
videoElement.classList.add('vrwp-video');
return new VideoMediaAdapter(videoElement);
}

View File

@@ -0,0 +1,133 @@
type MediaControllerOptions = {
is2DModeActive: () => boolean;
on2DPlaybackResume: () => void;
playButton?: HTMLButtonElement;
video: HTMLVideoElement;
};
type HandleMediaEndedOptions = {
cleanupFailedVrExit: () => void;
exitVr: () => Promise<void>;
isIn2DMode: () => boolean;
isInVr: () => boolean;
on2DEnded: () => 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({
cleanupFailedVrExit,
exitVr,
isIn2DMode,
isInVr,
on2DEnded,
resetToOriginalState
}: HandleMediaEndedOptions): void {
this.pauseIfPlaying();
if (isInVr()) {
exitVr().catch((err) => {
console.error('Error during automatic VR exit on video end:', err);
cleanupFailedVrExit();
});
return;
}
if (isIn2DMode()) {
on2DEnded();
return;
}
resetToOriginalState();
}
hidePlayButton(): void {
this.playButton?.classList.add('hidden');
}
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;
}
togglePlayPause(): void {
if (!this.video.currentSrc) return;
if (this.video.paused || this.video.ended) {
if (this.video.ended && this.is2DModeActive()) {
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,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,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,268 @@
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';
type TwoDModeCallbacks = {
createMediaTexture: () => any;
forward: () => void;
positionPlaneForPresentation: (isFallback2D?: boolean) => void;
rewind: () => void;
seekToProgress: (progress: number) => void;
showActiveContentMesh: () => void;
toggleMute: () => void;
togglePlayPause: () => void;
};
type TwoDModeOptions = {
callbacks: TwoDModeCallbacks;
fullscreenTarget: HTMLElement;
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 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.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: {
onForward: () => {
this.callbacks.forward();
},
onMute: () => {
this.callbacks.toggleMute();
},
onPlayPause: this.callbacks.togglePlayPause,
onRewind: () => {
this.callbacks.rewind();
},
onSeek: (progress) => {
this.callbacks.seekToProgress(progress);
}
},
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();
}
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;
const video = this.getVideo();
if (video) {
this.controls.updateTime(video.currentTime, video.duration);
}
}
updatePlaybackButton(): void {
if (!this.active) return;
const video = this.getVideo();
if (video) {
this.controls.updatePlaybackButton(video.paused || video.ended);
}
}
updateMuteButton(): void {
if (!this.active) return;
const video = this.getVideo();
if (video) {
this.controls.updateMuteButton(video.muted);
}
}
handleVideoEnd(): void {
if (!this.active) 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,80 @@
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);
}

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,69 @@
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 readonly 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;
}
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,100 @@
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;
}

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

@@ -0,0 +1,27 @@
declare module 'https://unpkg.com/three/build/three.module.js' {
export const Matrix4: any;
export const CanvasTexture: 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,586 @@
import {
PLANE_2D_DISTANCE,
PLANE_DISTANCE,
type ProjectionMode
} from './config.js';
import { bootstrapPlayer } from './bootstrap.js';
import { createContentScene } from './rendering/content-scene.js';
import {
applySbsTextureWindow as applySbsTextureWindowCore,
hideContentMeshes as hideContentMeshesCore,
positionPlaneForPresentation as positionPlaneForPresentationCore,
showActiveContentMesh as showActiveContentMeshCore
} from './rendering/projection.js';
import { createVideoTexture as createVideoTextureCore } from './rendering/three-utils.js';
import { FallbackCameraControls } from './modes/fallback-camera-controls.js';
import { MediaController } from './media/media-controller.js';
import {
createVrController,
handleVrControllerSelect
} from './xr/vr-controller-interactions.js';
import { bindVideoEvents } from './media/video-events.js';
import {
createVrControlPanel,
type VrControlPanel,
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';
const _playerBase = new URL('.', import.meta.url).href;
let playerContainer, projectionMode: ProjectionMode;
let scene, camera, renderer, video, sphereMaterial;
let vr180Mesh, planeMesh, activeContentMesh;
let xrSession = null;
let raycaster, uiElements = [];
let mediaAdapter: SupportedMediaAdapter | undefined;
let playBtn;
let frameCounter = 0;
let isXrLoopActive = false;
let vrControlPanel;
let mediaController: MediaController | undefined;
let textureManager: MediaTextureManager<HTMLVideoElement> | undefined;
let vrPanel: VrControlPanel | undefined;
let twoDMode: TwoDMode | undefined;
const vrPanelVisibility = new VrPanelVisibility();
// 2D Camera Controls
let camera2D;
let fallbackCameraControls: FallbackCameraControls | undefined;
bootstrapPlayer(_playerBase, (context) => {
playerContainer = context.playerContainer;
projectionMode = context.projectionMode;
mediaAdapter = context.mediaAdapter;
video = mediaAdapter.element;
playBtn = context.playButton;
init();
});
function applySbsTextureWindow(renderingRenderer, activeCamera, material) {
applySbsTextureWindowCore(renderingRenderer, activeCamera, material, is2DModeActive());
}
function hideContentMeshes() {
hideContentMeshesCore(vr180Mesh, planeMesh);
}
function showActiveContentMesh() {
showActiveContentMeshCore(vr180Mesh, planeMesh, activeContentMesh);
}
function positionPlaneForPresentation(isFallback2D = false) {
positionPlaneForPresentationCore(planeMesh, camera2D, isFallback2D, PLANE_DISTANCE, PLANE_2D_DISTANCE);
}
function createVideoTexture() {
if (!textureManager) {
throw new Error('Video texture manager is not initialized.');
}
return textureManager.create();
}
function is2DModeActive() {
return twoDMode?.isActive ?? false;
}
function closeActiveXrSessionAfterContextLoss() {
if (!xrSession) return;
const sessionToClose = xrSession;
xrSession = null;
sessionToClose.removeEventListener('end', onVRSessionEnd);
sessionToClose.end().catch(e => {
console.error("Error ending session on context lost:", e);
}).finally(() => {
onVRSessionEnd({ session: sessionToClose });
});
}
function restoreVideoTextureAfterContextRestored() {
if (mediaAdapter && sphereMaterial && activeContentMesh && activeContentMesh.visible && renderer.xr.isPresenting && xrSession) {
textureManager?.assignToMaterial(sphereMaterial);
updateVRPlayPauseButtonIcon();
updateVRVolumeButtonIcon();
console.log("Re-initialized media texture after context restoration during VR.");
}
}
function getMediaTitle() {
return mediaAdapter?.getTitle() || 'Video Title';
}
function init() {
try {
const playerRenderer = createPlayerRenderer(playerContainer, {
closeActiveXrSession: closeActiveXrSessionAfterContextLoss,
hasActiveXrSession: () => !!xrSession,
restoreAfterContextRestored: restoreVideoTextureAfterContextRestored
});
scene = playerRenderer.scene;
camera = playerRenderer.camera;
renderer = playerRenderer.renderer;
if (!mediaAdapter) {
throw new Error('Media adapter is not initialized.');
}
video = mediaAdapter.element;
textureManager = new MediaTextureManager(
mediaAdapter.textureSource,
createVideoTextureCore,
() => mediaAdapter?.shouldUpdateTexture() ?? false
);
mediaController = new MediaController({
is2DModeActive,
on2DPlaybackResume: show2DControlPanel,
playButton: playBtn,
video
});
const contentScene = createContentScene(scene, projectionMode, (renderer, scene, activeCamera, geometry, material, group) => {
applySbsTextureWindow(renderer, activeCamera, material);
});
sphereMaterial = contentScene.material;
vr180Mesh = contentScene.vr180Mesh;
planeMesh = contentScene.planeMesh;
activeContentMesh = contentScene.activeContentMesh;
uiElements.push(activeContentMesh);
camera2D = contentScene.fallbackCamera;
fallbackCameraControls = new FallbackCameraControls(camera2D, {
hideControls: hide2DControlPanel,
isEnabled: is2DModeActive,
showControls: show2DControlPanel
});
twoDMode = new TwoDMode({
callbacks: {
createMediaTexture: createVideoTexture,
forward: () => mediaController?.forward(),
positionPlaneForPresentation,
rewind: () => mediaController?.rewind(),
seekToProgress: (progress) => mediaController?.seekToProgress(progress),
showActiveContentMesh,
toggleMute: () => mediaController?.toggleMute(),
togglePlayPause: () => mediaController?.togglePlayPause()
},
fullscreenTarget: playerContainer,
getActiveContentMesh: () => activeContentMesh,
getCamera: () => camera2D,
getCameraControls: () => fallbackCameraControls,
getMaterial: () => sphereMaterial,
getMediaElement: () => mediaAdapter?.element,
getRenderer: () => renderer,
getScene: () => scene,
getVideo: () => video,
playerContainer,
projectionMode,
title: getMediaTitle()
});
} catch (e) {
console.error("INIT_ERROR (Phase 1 - Core Setup):", e);
renderer = null;
return;
}
try { // Phase 2: VR Control Panel UI
vrPanel = createVrControlPanel(scene, getMediaTitle());
vrControlPanel = vrPanel.group;
vrPanelVisibility.setPanel(vrPanel);
uiElements.push(...vrPanel.interactables);
raycaster = createVrController(scene, renderer, onSelectStartVR).raycaster;
} catch (e) {
console.error("INIT_ERROR (Phase 2 - VR Controls Setup):", e);
}
try { // Phase 3: Event Listeners
if (playBtn) {
playBtn.addEventListener('click', handleEnterVRButtonClick);
}
window.addEventListener('resize', onWindowResize);
if (video) {
bindVideoEvents({
onEnded: onVideoEnded,
onPlaybackStateChange: () => {
updateVRPlayPauseButtonIcon();
update2DPlayPauseButton();
},
onTimelineChange: () => {
updateSeekBarAppearance();
update2DControlPanel();
},
onVolumeChange: () => {
updateVRVolumeButtonIcon();
update2DMuteButton();
},
playButton: playBtn,
video
});
}
} catch (e) {
console.error("INIT_ERROR (Phase 3 - Event Listeners):", e);
}
}
function updateVRPlayPauseButtonIcon() {
if (!video) {
return;
}
updateVrPlayPauseButtonIcon(vrPanel, video.paused || video.ended);
}
function updateVRVolumeButtonIcon() {
if (!video) {
return;
}
updateVrVolumeButtonIcon(vrPanel, video.muted || video.volume === 0);
}
function updateSeekBarAppearance() {
const progress = video && isFinite(video.duration) && video.duration > 0
? video.currentTime / video.duration
: null;
updateVrSeekBarAppearance(vrPanel, progress);
}
function animatePanelFade(timestamp) {
vrPanelVisibility.updateFade(timestamp);
}
function showPanel() {
vrPanelVisibility.show();
}
function hidePanel() {
vrPanelVisibility.hide();
}
function onWindowResize() {
if (!renderer) return;
if (twoDMode?.resize()) return;
resizePlayerRenderer({
camera,
camera2D,
is2DMode: false,
onFallbackResize: () => {},
playerContainer,
renderer
});
}
function show2DControlPanel() {
twoDMode?.showControls();
}
function hide2DControlPanel() {
twoDMode?.hideControls();
}
function update2DControlPanel() {
twoDMode?.updateTimeline();
}
function update2DPlayPauseButton() {
twoDMode?.updatePlaybackButton();
}
function update2DMuteButton() {
twoDMode?.updateMuteButton();
}
function handle2DVideoEnd() {
twoDMode?.handleVideoEnd();
}
function resetToOriginalState() {
if (mediaController) {
mediaController.resetToOriginalState();
} else if (playBtn) {
playBtn.classList.remove('hidden');
playBtn.disabled = false;
}
if (twoDMode?.isActive) {
twoDMode.stop();
onWindowResize();
}
}
function onVideoEnded() {
if (!mediaController) {
resetToOriginalState();
return;
}
mediaController.handleEnded({
cleanupFailedVrExit,
exitVr: actualSessionToggle,
isIn2DMode: is2DModeActive,
isInVr: () => Boolean(xrSession && renderer && renderer.xr.isPresenting),
on2DEnded: handle2DVideoEnd,
resetToOriginalState
});
}
function cleanupFailedVrExit() {
if (xrSession) {
const sessionToClean = xrSession;
xrSession = null;
sessionToClean.removeEventListener('end', onVRSessionEnd);
sessionToClean.end().catch(e => {}).finally(() => onVRSessionEnd({session: sessionToClean}));
} else {
onVRSessionEnd({session: null});
}
}
function onSelectStartVR(event) {
handleVrControllerSelect(event, {
exitVr: () => {
if (xrSession) actualSessionToggle();
},
forward: () => {
mediaController?.forward();
updateSeekBarAppearance();
},
hidePanel,
isPanelVisible: () => vrPanelVisibility.isVisible,
raycaster,
rewind: () => {
mediaController?.rewind();
updateSeekBarAppearance();
},
seek: (progress) => {
mediaController?.seekToProgress(progress);
updateSeekBarAppearance();
},
showPanel,
toggleMute: () => {
mediaController?.toggleMute();
},
togglePlayPause: () => {
mediaController?.togglePlayPause();
},
uiElements,
vrPanel
});
}
async function handleEnterVRButtonClick() {
if (!mediaAdapter) {
console.error("Media element not found for VR button click.");
return;
}
// Hide the play button after click
mediaController?.hidePlayButton();
// Check if VR is supported
if (playBtn.dataset.xrSupported === "true") {
// VR is supported - use VR functionality
await actualSessionToggle();
} else {
// VR is not supported - start 2D rectilinear mode
twoDMode?.start();
}
}
async function actualSessionToggle() {
if (!renderer || !renderer.isWebGLRenderer) {
console.error("CRITICAL_ERROR: actualSessionToggle: renderer is NOT a WebGLRenderer or is null!", renderer);
return;
}
if (xrSession) { // --- EXITING VR ---
const sessionToClose = xrSession;
xrSession = null;
if (vrControlPanel) {
vrPanelVisibility.hideImmediately();
}
sessionToClose.end().catch(err => {
console.error("Error calling .end() on session:", err);
onVRSessionEnd({ session: sessionToClose });
});
} else { // --- ENTERING VR ---
try {
const session = await navigator.xr.requestSession('immersive-vr', {
requiredFeatures: ['local-floor'],
});
if (!session) { throw new Error("requestSession returned no session."); }
xrSession = session;
xrSession.addEventListener('end', onVRSessionEnd);
mediaAdapter?.hideElement();
if (mediaController && video && (video.paused || video.ended)) {
try {
await mediaController.play();
} catch (playError) {
console.error("Failed to play video after obtaining XR session:", playError);
}
}
if (camera) camera.updateProjectionMatrix();
positionPlaneForPresentation(false);
textureManager?.dispose();
if (!mediaAdapter) {
throw new Error("Media adapter not available for creating texture.");
}
if (!activeContentMesh || !sphereMaterial) {
throw new Error("VR mesh components not ready for texture.");
}
if (!textureManager) {
throw new Error("Video texture manager is not initialized.");
}
textureManager.assignToMaterial(sphereMaterial);
showActiveContentMesh();
updateVRPlayPauseButtonIcon();
updateVRVolumeButtonIcon();
if (vrControlPanel) {
vrPanelVisibility.hideImmediately();
}
await renderer.xr.setSession(xrSession);
isXrLoopActive = true;
renderer.setAnimationLoop(renderXR);
frameCounter = 0;
} catch (err) {
const sessionStartError = "XR_ERROR: Failed to start VR session: " + (err.message || String(err));
console.error(sessionStartError, err);
isXrLoopActive = false;
hideContentMeshes();
textureManager?.clearMaterial(sphereMaterial);
if (vrControlPanel) {
vrPanelVisibility.hideImmediately();
}
if (xrSession) {
xrSession.removeEventListener('end', onVRSessionEnd);
const tempSession = xrSession;
xrSession = null;
tempSession.end().catch(e => {}).finally(() => {
onVRSessionEnd({session: tempSession});
});
} else {
onVRSessionEnd({session: null});
}
if (renderer && renderer.getAnimationLoop && renderer.getAnimationLoop()) {
renderer.setAnimationLoop(null);
}
}
}
}
function onVRSessionEnd(event) {
const endedSession = event.session;
isXrLoopActive = false;
if (renderer) {
if (renderer.getAnimationLoop && renderer.getAnimationLoop()) {
renderer.setAnimationLoop(null);
}
}
mediaAdapter?.showElement();
mediaController?.pauseIfPlaying();
textureManager?.clearMaterial(sphereMaterial);
hideContentMeshes();
if (vrControlPanel) {
vrPanelVisibility.hideImmediately();
}
if (endedSession && typeof endedSession.removeEventListener === 'function') {
endedSession.removeEventListener('end', onVRSessionEnd);
}
if (xrSession === endedSession || xrSession === null) {
xrSession = null;
} else if (xrSession && endedSession) {
console.warn("onVRSessionEnd: Global xrSession was different from the endedSession. Global xrSession:", xrSession, "Ended session:", endedSession);
xrSession = null;
}
// Reset to original state when exiting VR
resetToOriginalState();
onWindowResize();
}
function renderXR(timestamp, frame) {
if (!isXrLoopActive) {
return;
}
frameCounter++;
if (!renderer || !renderer.xr || !renderer.xr.isPresenting) {
console.warn("renderXR called but not in a valid XR presenting state. Stopping loop.");
isXrLoopActive = false;
if (renderer && renderer.getAnimationLoop && renderer.getAnimationLoop()) {
renderer.setAnimationLoop(null);
}
return;
}
if (vrPanelVisibility.isFading) {
animatePanelFade(timestamp);
}
if (!frame) {
console.warn("renderXR called without an XRFrame. Skipping render.");
return;
}
if (frameCounter > 0 && frameCounter % 3600 === 0) {
const gl = renderer.getContext();
const error = gl.getError();
if (error !== gl.NO_ERROR) {
console.error(`WEBGL_ERROR_IN_RENDER_LOOP (F${frameCounter}):`, error, gl.enumToString ? gl.enumToString(error) : error);
}
}
try {
textureManager?.updateIfNeeded();
renderer.render(scene, camera);
} catch (error) {
const renderErrorMsg = "ERROR_IN_RENDERXR_LOOP (F" + frameCounter + "): " + (error.message || String(error));
console.error(renderErrorMsg, error);
console.error("Render loop error. Attempting to exit VR.");
isXrLoopActive = false;
const sessionToCloseOnError = xrSession;
xrSession = null;
if (sessionToCloseOnError) {
sessionToCloseOnError.removeEventListener('end', onVRSessionEnd);
sessionToCloseOnError.end().catch(e => {
console.error("Error trying to end session after render loop crash:", e);
}).finally(() => {
onVRSessionEnd({ session: sessionToCloseOnError });
});
} else {
onVRSessionEnd({ session: null });
}
}
}

View File

@@ -0,0 +1,322 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
import { drawLucideIcon } from '../dom/icons.js';
import { createLucideButtonTexture, drawRoundedRect } from '../rendering/three-utils.js';
type ButtonLayout = {
centerX: number;
centerY: number;
name: string;
size: number;
texture: any;
};
export type VrControlPanel = {
exitButtonMesh: any;
forwardButtonMesh: any;
group: any;
interactables: 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 FIGMA_PANEL_WIDTH_PX = 450;
const FIGMA_PANEL_HEIGHT_PX = 132;
const FIGMA_CORNER_RADIUS_PX = 30;
const FIGMA_TITLE_FONT_SIZE_PX = 14;
const FIGMA_TITLE_MARGIN_TOP_PX = 20;
const FIGMA_SEEK_BAR_WIDTH_PX = 386;
const FIGMA_SEEK_BAR_HEIGHT_PX = 5;
const FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX = (FIGMA_PANEL_HEIGHT_PX / 2) - 54;
const FIGMA_PLAYPAUSE_BUTTON_SIZE_PX = 44;
const FIGMA_PLAYPAUSE_BUTTON_X_PX = 225;
const FIGMA_PLAYPAUSE_BUTTON_Y_PX = 90;
const FIGMA_REWIND_BUTTON_SIZE_PX = 44;
const FIGMA_REWIND_BUTTON_X_PX = 169;
const FIGMA_REWIND_BUTTON_Y_PX = 90;
const FIGMA_FORWARD_BUTTON_SIZE_PX = 44;
const FIGMA_FORWARD_BUTTON_X_PX = 281;
const FIGMA_FORWARD_BUTTON_Y_PX = 90;
const FIGMA_EXIT_BUTTON_SIZE_PX = 44;
const FIGMA_EXIT_BUTTON_X_PX = 42;
const FIGMA_EXIT_BUTTON_Y_PX = 90;
const FIGMA_VOLUME_BUTTON_SIZE_PX = 44;
const FIGMA_VOLUME_BUTTON_X_PX = 408;
const FIGMA_VOLUME_BUTTON_Y_PX = 90;
const WORLD_PANEL_WIDTH = 1.5;
const SCALE_FACTOR = WORLD_PANEL_WIDTH / FIGMA_PANEL_WIDTH_PX;
const WORLD_PANEL_HEIGHT = FIGMA_PANEL_HEIGHT_PX * SCALE_FACTOR;
const WORLD_SEEK_BAR_WIDTH = FIGMA_SEEK_BAR_WIDTH_PX * SCALE_FACTOR;
const WORLD_SEEK_BAR_TRACK_HEIGHT = FIGMA_SEEK_BAR_HEIGHT_PX * SCALE_FACTOR;
const WORLD_SEEK_BAR_PROGRESS_HEIGHT = (FIGMA_SEEK_BAR_HEIGHT_PX - 1) * SCALE_FACTOR;
const WORLD_SEEK_BAR_Y_OFFSET = FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX * SCALE_FACTOR;
const WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER = 5;
const PANEL_TEXTURE_WIDTH = 1024;
const PANEL_TEXTURE_HEIGHT = Math.round(PANEL_TEXTURE_WIDTH * (FIGMA_PANEL_HEIGHT_PX / FIGMA_PANEL_WIDTH_PX));
const VR_BUTTON_TEXTURE_SIZE = 128;
const VR_BUTTON_ICON_SIZE = 82;
export function createVrControlPanel(scene: any, title: string): VrControlPanel {
const group = new THREE.Group();
group.position.set(0, 0.5, -1.8);
group.rotation.x = 0;
scene.add(group);
const interactables: any[] = [];
const panelMesh = createPanelBackground(title);
group.add(panelMesh);
interactables.push(panelMesh);
const seekBarTrackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 });
const seekBarTrackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
const seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial);
seekBarTrackMesh.name = 'seekBarTrackVisual';
seekBarTrackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
seekBarTrackMesh.position.z = 0.01;
seekBarTrackMesh.renderOrder = 1;
group.add(seekBarTrackMesh);
const seekBarProgressMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 });
const seekBarProgressGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_PROGRESS_HEIGHT);
const seekBarProgressMesh = new THREE.Mesh(seekBarProgressGeometry, seekBarProgressMaterial);
seekBarProgressMesh.name = 'seekBarProgressVisual';
seekBarProgressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + SCALE_FACTOR;
seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
seekBarProgressMesh.position.z = 0.015;
seekBarProgressMesh.scale.x = 0.001;
seekBarProgressMesh.renderOrder = 2;
group.add(seekBarProgressMesh);
const seekBarHitAreaGeometry = new THREE.PlaneGeometry(
WORLD_SEEK_BAR_WIDTH,
WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER
);
const seekBarHitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 });
const seekBarHitAreaMesh = new THREE.Mesh(seekBarHitAreaGeometry, seekBarHitAreaMaterial);
seekBarHitAreaMesh.name = 'seekBarHitArea';
seekBarHitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
seekBarHitAreaMesh.position.z = 0.012;
seekBarHitAreaMesh.renderOrder = 2;
group.add(seekBarHitAreaMesh);
interactables.push(seekBarHitAreaMesh);
const playPauseButtonCanvas = document.createElement('canvas');
playPauseButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
playPauseButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
const playPauseButtonContext = playPauseButtonCanvas.getContext('2d');
const playPauseButtonTexture = new THREE.CanvasTexture(playPauseButtonCanvas);
playPauseButtonTexture.minFilter = THREE.LinearFilter;
const playPauseButtonMesh = createButtonMesh({
centerX: FIGMA_PLAYPAUSE_BUTTON_X_PX,
centerY: FIGMA_PLAYPAUSE_BUTTON_Y_PX,
name: 'vrPlayPauseButton',
size: FIGMA_PLAYPAUSE_BUTTON_SIZE_PX,
texture: playPauseButtonTexture
});
group.add(playPauseButtonMesh);
interactables.push(playPauseButtonMesh);
const rewindButtonMesh = createButtonMesh({
centerX: FIGMA_REWIND_BUTTON_X_PX,
centerY: FIGMA_REWIND_BUTTON_Y_PX,
name: 'vrRewindButton',
size: FIGMA_REWIND_BUTTON_SIZE_PX,
texture: createLucideButtonTexture('rotate-ccw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
});
group.add(rewindButtonMesh);
interactables.push(rewindButtonMesh);
const forwardButtonMesh = createButtonMesh({
centerX: FIGMA_FORWARD_BUTTON_X_PX,
centerY: FIGMA_FORWARD_BUTTON_Y_PX,
name: 'vrForwardButton',
size: FIGMA_FORWARD_BUTTON_SIZE_PX,
texture: createLucideButtonTexture('rotate-cw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
});
group.add(forwardButtonMesh);
interactables.push(forwardButtonMesh);
const exitButtonMesh = createButtonMesh({
centerX: FIGMA_EXIT_BUTTON_X_PX,
centerY: FIGMA_EXIT_BUTTON_Y_PX,
name: 'vrExitButton',
size: FIGMA_EXIT_BUTTON_SIZE_PX,
texture: createLucideButtonTexture('log-out', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE)
});
group.add(exitButtonMesh);
interactables.push(exitButtonMesh);
const volumeButtonCanvas = document.createElement('canvas');
volumeButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
volumeButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
const volumeButtonContext = volumeButtonCanvas.getContext('2d');
const volumeButtonTexture = new THREE.CanvasTexture(volumeButtonCanvas);
volumeButtonTexture.minFilter = THREE.LinearFilter;
const volumeButtonMesh = createButtonMesh({
centerX: FIGMA_VOLUME_BUTTON_X_PX,
centerY: FIGMA_VOLUME_BUTTON_Y_PX,
name: 'vrVolumeButton',
size: FIGMA_VOLUME_BUTTON_SIZE_PX,
texture: volumeButtonTexture
});
group.add(volumeButtonMesh);
interactables.push(volumeButtonMesh);
group.visible = false;
return {
exitButtonMesh,
forwardButtonMesh,
group,
interactables,
playPauseButtonCanvas,
playPauseButtonContext,
playPauseButtonMesh,
playPauseButtonTexture,
rewindButtonMesh,
seekBarHitAreaMesh,
seekBarProgressMesh,
seekBarTrackMesh,
volumeButtonCanvas,
volumeButtonContext,
volumeButtonMesh,
volumeButtonTexture
};
}
export function updateVrPlayPauseButtonIcon(panel: VrControlPanel | undefined, isPausedOrEnded: boolean): void {
if (!panel?.playPauseButtonContext || !panel.playPauseButtonTexture) return;
const ctx = panel.playPauseButtonContext;
const canvas = panel.playPauseButtonCanvas;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
drawLucideIcon(ctx, isPausedOrEnded ? 'play' : 'pause', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
panel.playPauseButtonTexture.needsUpdate = true;
}
export function updateVrVolumeButtonIcon(panel: VrControlPanel | undefined, isMuted: boolean): void {
if (!panel?.volumeButtonContext || !panel.volumeButtonTexture) return;
const ctx = panel.volumeButtonContext;
const canvas = panel.volumeButtonCanvas;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
drawLucideIcon(ctx, isMuted ? 'volume-x' : 'volume-2', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
panel.volumeButtonTexture.needsUpdate = true;
}
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));
}
function createPanelBackground(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;
const panelMaterial = new THREE.MeshBasicMaterial({
map: panelTexture,
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;
}
function createButtonMesh({ centerX, centerY, name, size, texture }: ButtonLayout): 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,109 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
import {
getSeekProgressFromIntersection,
type VrControlPanel
} from './vr-control-panel.js';
type VrControllerSelectionOptions = {
exitVr: () => void;
forward: () => void;
hidePanel: () => void;
isPanelVisible: () => boolean;
raycaster: any;
rewind: () => void;
seek: (progress: number) => void;
showPanel: () => void;
toggleMute: () => void;
togglePlayPause: () => void;
uiElements: any[];
vrPanel: VrControlPanel | undefined;
};
const tempMatrix = new THREE.Matrix4();
export function createVrController(scene: any, renderer: any, onSelectStart: (event: any) => void): {
controller: any;
raycaster: any;
} {
const controller = renderer.xr.getController(0);
controller.addEventListener('selectstart', onSelectStart);
const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.5 });
const lineGeometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, -5)
]);
controller.add(new THREE.Line(lineGeometry, lineMaterial));
scene.add(controller);
const raycaster = new THREE.Raycaster();
raycaster.near = 0.1;
raycaster.far = 5;
return { controller, raycaster };
}
export function handleVrControllerSelect(event: any, options: VrControllerSelectionOptions): void {
const controller = event.target;
if (!options.raycaster) return;
controller.updateMatrixWorld();
tempMatrix.identity().extractRotation(controller.matrixWorld);
options.raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
options.raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
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 === 'seekBarHitArea') {
options.showPanel();
options.seek(getSeekProgressFromIntersection(options.vrPanel, intersectionPoint));
return;
}
togglePanel(options);
}
function togglePanel(options: VrControllerSelectionOptions): void {
if (options.isPanelVisible()) {
options.hidePanel();
} else {
options.showPanel();
}
}

View File

@@ -0,0 +1,111 @@
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 {
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();
}
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,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createMediaAdapter,
VideoMediaAdapter
} from '../vr180player/media/media-adapter.js';
function createVideo({
ended = false,
paused = false,
source = 'https://cdn.example.com/videos/demo-video.mp4',
title = ''
} = {}) {
return {
classList: {
values: [],
add(value) {
this.values.push(value);
}
},
ended,
loadCount: 0,
paused,
style: { display: '' },
getAttribute(name) {
return name === 'title' ? title : '';
},
load() {
this.loadCount += 1;
},
querySelector(selector) {
if (selector !== 'source' || !source) {
return null;
}
return { src: source };
}
};
}
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,
dynamicTexture: 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('createMediaAdapter finds and marks the supported video element', () => {
const video = createVideo();
const playerContainer = {
querySelector(selector) {
return selector === 'video' ? video : null;
}
};
const adapter = createMediaAdapter(playerContainer);
assert.ok(adapter instanceof VideoMediaAdapter);
assert.equal(adapter.element, video);
assert.deepEqual(video.classList.values, ['vrwp-video']);
});

View File

@@ -0,0 +1,195 @@
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,
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 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 in 2D mode', 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);
});
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', async () => {
const vrCalls = [];
const { controller } = createController({
video: createVideo({ paused: false })
});
controller.handleEnded({
cleanupFailedVrExit: () => vrCalls.push('cleanup'),
exitVr: () => {
vrCalls.push('exit');
return Promise.resolve();
},
isIn2DMode: () => false,
isInVr: () => true,
on2DEnded: () => vrCalls.push('2d'),
resetToOriginalState: () => vrCalls.push('reset')
});
await Promise.resolve();
assert.deepEqual(vrCalls, ['exit']);
const twoDCalls = [];
controller.handleEnded({
cleanupFailedVrExit: () => twoDCalls.push('cleanup'),
exitVr: () => Promise.resolve(),
isIn2DMode: () => true,
isInVr: () => false,
on2DEnded: () => twoDCalls.push('2d'),
resetToOriginalState: () => twoDCalls.push('reset')
});
assert.deepEqual(twoDCalls, ['2d']);
const idleCalls = [];
controller.handleEnded({
cleanupFailedVrExit: () => idleCalls.push('cleanup'),
exitVr: () => Promise.resolve(),
isIn2DMode: () => false,
isInVr: () => false,
on2DEnded: () => idleCalls.push('2d'),
resetToOriginalState: () => idleCalls.push('reset')
});
assert.deepEqual(idleCalls, ['reset']);
});

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

@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applySbsTextureWindow,
hideContentMeshes,
isLeftEyeCamera,
positionPlaneForPresentation,
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) },
projectionMatrix: { elements: new Array(16).fill(0).with(8, projectionOffset) }
};
}
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]);
});

View File

@@ -0,0 +1,77 @@
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 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,17 @@
#vr-container {
.vrwp {
position: relative;
display: inline-block;
width: 100%;
}
video {
.vrwp-video,
.vrwp canvas {
width: 100%;
height: auto;
aspect-ratio: 16/9;
aspect-ratio: 16 / 9;
}
#playBtn {
.vrwp-play-button {
position: absolute;
top: 50%;
left: 50%;
@@ -25,41 +26,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 +69,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 +77,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 +117,7 @@ video {
position: relative;
}
#played {
.vrwp-played {
border-radius: 2px;
background: #fff;
height: 4px;
@@ -125,66 +125,53 @@ 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;
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-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);
}
#mute.muted {
background-image: url(images/mute.png);
}
#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 +179,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