forked from EXT/VR180-Web-Player
Compare commits
28 Commits
24a166046e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddbcebf80a | ||
|
|
db81ea3721 | ||
|
|
fbfdc1c575 | ||
|
|
a4bbd71b31 | ||
|
|
4c8eed0bfe | ||
|
|
469dc81491 | ||
|
|
c86490542d | ||
|
|
731ee4e647 | ||
|
|
69511e4549 | ||
|
|
229c25947a | ||
|
|
cdaed5c712 | ||
|
|
b674df1555 | ||
|
|
1d4b3ce307 | ||
|
|
776c7c0629 | ||
|
|
fbdb733f13 | ||
|
|
a470d4bdc7 | ||
|
|
ea184ba448 | ||
|
|
707cad3719 | ||
|
|
857c9ac980 | ||
|
|
c28386ccdd | ||
|
|
ba3c2785d8 | ||
|
|
c1fbfd3b5e | ||
|
|
5397bf1a5c | ||
|
|
82d5c31ab2 | ||
|
|
95b9bc7cdc | ||
|
|
0879f1685a | ||
|
|
8402fcd640 | ||
|
|
030a8b724b |
15
.env.r2.example
Normal file
15
.env.r2.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# Copy this file to .env.r2 and fill in your R2 details.
|
||||
# Do not commit .env.r2; it is ignored by git.
|
||||
|
||||
R2_ACCOUNT_ID=your_cloudflare_account_id
|
||||
R2_ACCESS_KEY_ID=your_r2_access_key_id
|
||||
R2_SECRET_ACCESS_KEY=your_r2_secret_access_key
|
||||
R2_BUCKET=your-r2-bucket-name
|
||||
|
||||
# Optional settings.
|
||||
# Local folder to upload after running npm run build.
|
||||
R2_SOURCE_DIR=vr180player
|
||||
# Object key prefix inside the bucket.
|
||||
R2_PREFIX=vr180player
|
||||
R2_ENDPOINT=
|
||||
R2_CACHE_CONTROL=public, max-age=31536000, immutable
|
||||
222
.gitea/workflows/publish-pages.yml
Normal file
222
.gitea/workflows/publish-pages.yml
Normal file
@@ -0,0 +1,222 @@
|
||||
name: Publish Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:22-bookworm
|
||||
env:
|
||||
R2_ENDPOINT: https://${{ secrets.F40_PAGES_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
|
||||
R2_ACCOUNT_ID: ${{ secrets.F40_PAGES_R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.F40_PAGES_R2_BUCKET }}
|
||||
SITE_NAME_OVERRIDE: ${{ vars.F40_PAGES_SITE_NAME }}
|
||||
BUILD_COMMAND_OVERRIDE: ${{ vars.F40_PAGES_BUILD_COMMAND }}
|
||||
OUTPUT_DIR_OVERRIDE: ${{ vars.F40_PAGES_OUTPUT_DIR }}
|
||||
DEFAULT_BUILD_COMMAND: ${{ vars.F40_PAGES_DEFAULT_BUILD_COMMAND }}
|
||||
DEFAULT_OUTPUT_DIR: ${{ vars.F40_PAGES_DEFAULT_OUTPUT_DIR }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Show runtime versions
|
||||
run: |
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
- name: Resolve settings
|
||||
run: |
|
||||
SITE_NAME="${SITE_NAME_OVERRIDE:-${GITHUB_REPOSITORY##*/}}"
|
||||
BUILD_COMMAND="${BUILD_COMMAND_OVERRIDE:-${DEFAULT_BUILD_COMMAND:-npm ci && npm run build:test-app}}"
|
||||
OUTPUT_DIR="${OUTPUT_DIR_OVERRIDE:-${DEFAULT_OUTPUT_DIR:-dist}}"
|
||||
|
||||
echo "SITE_NAME=$SITE_NAME" >> "$GITHUB_ENV"
|
||||
echo "BUILD_COMMAND=$BUILD_COMMAND" >> "$GITHUB_ENV"
|
||||
echo "OUTPUT_DIR=$OUTPUT_DIR" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate publish settings
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.F40_PAGES_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.F40_PAGES_R2_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
missing=0
|
||||
for name in R2_ACCOUNT_ID R2_BUCKET AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
|
||||
value="$(eval "printf '%s' \"\${$name:-}\"")"
|
||||
if [ -z "$value" ]; then
|
||||
echo "::error::$name is required"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
if [ "$missing" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$SITE_NAME" in
|
||||
""|*[!A-Za-z0-9._-]*)
|
||||
echo "::error::SITE_NAME must contain only letters, numbers, '.', '_', and '-'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Install R2 publisher
|
||||
run: |
|
||||
PUBLISHER_DIR="${RUNNER_TEMP:-/tmp}/f40-pages-publisher"
|
||||
mkdir -p "$PUBLISHER_DIR"
|
||||
npm install --prefix "$PUBLISHER_DIR" --no-audit --no-fund @aws-sdk/client-s3
|
||||
echo "PUBLISHER_DIR=$PUBLISHER_DIR" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build static site
|
||||
run: sh -c "$BUILD_COMMAND"
|
||||
|
||||
- name: Validate build output
|
||||
run: |
|
||||
if [ ! -d "$OUTPUT_DIR" ]; then
|
||||
echo "::error::Build output directory '$OUTPUT_DIR' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$OUTPUT_DIR/index.html" ]; then
|
||||
echo "::warning::'$OUTPUT_DIR/index.html' does not exist; /$SITE_NAME/ will return 404"
|
||||
fi
|
||||
|
||||
- name: Publish to R2
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.F40_PAGES_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.F40_PAGES_R2_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
RUN_ATTEMPT="${GITHUB_RUN_ATTEMPT:-1}"
|
||||
RELEASE="${GITHUB_SHA}-${RUN_ATTEMPT}"
|
||||
PREFIX="sites/${SITE_NAME}/releases/${RELEASE}"
|
||||
export RELEASE PREFIX
|
||||
|
||||
cat > "$PUBLISHER_DIR/publish.mjs" <<'EOF'
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { readdir, stat, writeFile } from "node:fs/promises";
|
||||
import { join, relative, sep } from "node:path";
|
||||
|
||||
const required = [
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"OUTPUT_DIR",
|
||||
"PREFIX",
|
||||
"R2_BUCKET",
|
||||
"R2_ENDPOINT",
|
||||
"RELEASE",
|
||||
"GITHUB_SHA",
|
||||
"SITE_NAME"
|
||||
];
|
||||
|
||||
for (const name of required) {
|
||||
if (!process.env[name]) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new S3Client({
|
||||
endpoint: process.env.R2_ENDPOINT,
|
||||
forcePathStyle: true,
|
||||
region: "auto",
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
|
||||
}
|
||||
});
|
||||
|
||||
const contentTypes = new Map([
|
||||
[".avif", "image/avif"],
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".gif", "image/gif"],
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".ico", "image/x-icon"],
|
||||
[".jpeg", "image/jpeg"],
|
||||
[".jpg", "image/jpeg"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".m4v", "video/mp4"],
|
||||
[".mjs", "text/javascript; charset=utf-8"],
|
||||
[".mov", "video/quicktime"],
|
||||
[".mp4", "video/mp4"],
|
||||
[".png", "image/png"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".txt", "text/plain; charset=utf-8"],
|
||||
[".wasm", "application/wasm"],
|
||||
[".webm", "video/webm"],
|
||||
[".webp", "image/webp"],
|
||||
[".xml", "application/xml; charset=utf-8"]
|
||||
]);
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(path)));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function contentTypeFor(path) {
|
||||
const lower = path.toLowerCase();
|
||||
const index = lower.lastIndexOf(".");
|
||||
if (index === -1) {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
return contentTypes.get(lower.slice(index)) ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
const outputDir = process.env.OUTPUT_DIR;
|
||||
const files = await walk(outputDir);
|
||||
|
||||
for (const file of files) {
|
||||
const relativePath = relative(outputDir, file).split(sep).join("/");
|
||||
const key = `${process.env.PREFIX}/${relativePath}`;
|
||||
const metadata = await stat(file);
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: process.env.R2_BUCKET,
|
||||
Key: key,
|
||||
Body: createReadStream(file),
|
||||
ContentLength: metadata.size,
|
||||
ContentType: contentTypeFor(file),
|
||||
CacheControl: "public,max-age=31536000,immutable"
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`uploaded ${key}`);
|
||||
}
|
||||
|
||||
const current = {
|
||||
site: process.env.SITE_NAME,
|
||||
release: process.env.RELEASE,
|
||||
sha: process.env.GITHUB_SHA,
|
||||
publishedAt: new Date().toISOString()
|
||||
};
|
||||
const currentPath = join(process.cwd(), "current.json");
|
||||
|
||||
await writeFile(currentPath, `${JSON.stringify(current)}\n`);
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: process.env.R2_BUCKET,
|
||||
Key: `sites/${process.env.SITE_NAME}/current.json`,
|
||||
Body: createReadStream(currentPath),
|
||||
ContentType: "application/json; charset=utf-8",
|
||||
CacheControl: "no-store"
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`published ${process.env.SITE_NAME} release ${process.env.RELEASE}`);
|
||||
EOF
|
||||
|
||||
node "$PUBLISHER_DIR/publish.mjs"
|
||||
@@ -7,17 +7,13 @@ on:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:22-bookworm
|
||||
|
||||
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
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
node_modules/
|
||||
.env.r2
|
||||
dist/
|
||||
|
||||
# Generated by `npm run build`.
|
||||
vr180player/*.css
|
||||
vr180player/*.js
|
||||
vr180player/**/*.js
|
||||
/media
|
||||
|
||||
161
README.md
161
README.md
@@ -1,49 +1,108 @@
|
||||
# VR Web Player
|
||||
A CDN-friendly web player for side-by-side stereoscopic video.
|
||||
A CDN-friendly web player for side-by-side stereoscopic video and still images.
|
||||
|
||||
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.
|
||||
- `plane`: a flat stereoscopic media plane in WebXR, with a normal flat left-eye fallback on non-XR browsers.
|
||||
|
||||
## How to use it
|
||||
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>
|
||||
```
|
||||
|
||||
Use `data-projection="plane"` for flat 3D video on a rectangular plane:
|
||||
Current F40 Pages CDN entrypoint:
|
||||
|
||||
```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>
|
||||
<script type="module" src="https://pages.f-40.com/VR-Web-Player/vr180player/vr180-player.js"></script>
|
||||
```
|
||||
|
||||
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`.
|
||||
```html
|
||||
<button
|
||||
type="button"
|
||||
data-vr-web-launcher
|
||||
data-media-type="image"
|
||||
data-projection="vr180"
|
||||
data-src="vr180-sbs-image.jpg"
|
||||
data-title="Temple Hall"
|
||||
data-crossorigin="anonymous">
|
||||
<img src="temple-thumb.jpg" alt="Temple Hall">
|
||||
</button>
|
||||
|
||||
## 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`.
|
||||
<script type="module" src="https://pages.f-40.com/VR-Web-Player/vr180player/vr180-player.js"></script>
|
||||
```
|
||||
|
||||
A page can contain any number of launchers. Each launcher represents one SBS media item. A launcher click goes straight into immersive WebXR when `immersive-vr` is supported. When immersive WebXR is unavailable, the same click opens a modal with the left-eye fallback view.
|
||||
|
||||
Launcher attributes:
|
||||
|
||||
- `data-vr-web-launcher`: required marker.
|
||||
- `data-src`: required media URL. For image carousels, provide at least two comma-separated image URLs.
|
||||
- `data-media-type="image|video"`: optional when the media type can be inferred from the URL extension.
|
||||
- `data-projection="vr180|plane"`: defaults to `vr180`.
|
||||
- `data-title`: optional display title.
|
||||
- `data-carousel`: optional image carousel mode.
|
||||
- `data-head-lock="auto|position|none"`: optional positional comfort mode. It defaults to `auto`, which position-locks `vr180` media to the headset to avoid false 6DoF parallax, while leaving `plane` media fixed like a screen.
|
||||
- `data-poster`, `data-type`, and `data-preload`: video helpers.
|
||||
- `data-crossorigin`: optional media CORS mode, usually `anonymous` for CDN media.
|
||||
|
||||
Use `data-projection="plane"` for flat 3D media on a rectangular plane:
|
||||
|
||||
```html
|
||||
<button
|
||||
type="button"
|
||||
data-vr-web-launcher
|
||||
data-media-type="video"
|
||||
data-projection="plane"
|
||||
data-src="flat-sbs-video.mp4"
|
||||
data-poster="poster.jpg"
|
||||
data-title="Flat 3D Demo"
|
||||
data-type="video/mp4"
|
||||
data-crossorigin="anonymous">
|
||||
<img src="poster.jpg" alt="Flat 3D Demo">
|
||||
</button>
|
||||
```
|
||||
|
||||
Use `data-carousel` for multiple SBS still images in one immersive session:
|
||||
|
||||
```html
|
||||
<button
|
||||
type="button"
|
||||
data-vr-web-launcher
|
||||
data-carousel
|
||||
data-media-type="image"
|
||||
data-projection="vr180"
|
||||
data-src="first-sbs-image.png, second-sbs-image.png"
|
||||
data-title="VR180 Stills"
|
||||
data-crossorigin="anonymous">
|
||||
<img src="first-thumb.jpg" alt="VR180 Stills">
|
||||
</button>
|
||||
```
|
||||
|
||||
`[data-vr-web-player]` is now an internal container created by the launcher at runtime. Authored pages should use `[data-vr-web-launcher]`.
|
||||
|
||||
## Media format
|
||||
This version supports side-by-side media only:
|
||||
|
||||
- Video: 2:1 side-by-side video using H.264 or HEVC in an mp4 file.
|
||||
- Image: side-by-side still images in browser-supported image formats such as PNG, JPEG, or WebP.
|
||||
|
||||
It does not support over-under, MV-HEVC, APMP, or `.aivu`.
|
||||
|
||||
## How it works
|
||||
When the 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.
|
||||
When the page loads, the script binds every `[data-vr-web-launcher]` on the page. When the user clicks a launcher, the player checks for `navigator.xr` and `immersive-vr` support, then splits between immersive entry and fallback modal display.
|
||||
|
||||
- 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.
|
||||
- In WebXR, `vr180` maps the left and right halves of the SBS media onto the matching eyes of a 180 degree sphere. In the default `auto` head-lock mode, the sphere follows headset position but not headset rotation.
|
||||
- In WebXR, `plane` maps the left and right halves onto the matching eyes of a floating 16:9 plane.
|
||||
- Outside WebXR, both modes render only the left half of the SBS media so viewers do not see the raw double image.
|
||||
- Video controls include a loop toggle for indefinite replay.
|
||||
- Static images show only applicable controls; playback, seek, and mute controls are video-only.
|
||||
- Image carousels replace skip buttons with previous/next image controls, so you can change stills without leaving the immersive session.
|
||||
- Controller pointers and lightweight controller overlays appear after controller interaction, then fade away after a short idle period so they do not distract from viewing.
|
||||
- Control icons are embedded from [Lucide](https://lucide.dev/) SVG definitions, so no PNG icon assets are required.
|
||||
|
||||
## Demo
|
||||
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.
|
||||
Run `npm run build`, then open `test-pages/index.html` through a local web server. The test hub has three options: a local file picker for browser-selected image/video media, one bundled SBS flat 3D image, and one bundled SBS VR180 image.
|
||||
|
||||
The deployed test app is expected at `https://pages.f-40.com/VR-Web-Player/` once the F40 Pages workflow has published a release.
|
||||
|
||||
For local experimentation, run:
|
||||
|
||||
@@ -53,6 +112,45 @@ npm run dev
|
||||
|
||||
This builds the TypeScript player once, then serves `index.html` with Vite at a local URL.
|
||||
|
||||
For headset testing, the page must be a secure context before the browser will expose immersive WebXR. A LAN URL such as `http://192.168.x.x:5173/` is useful for checking layout and media loading, but it will usually not show the headset's immersive VR prompt. Use an HTTPS URL with a trusted certificate, a trusted tunnel, or a deployed CDN/Pages URL for immersive testing.
|
||||
|
||||
## F40 Pages deploy
|
||||
This repo includes a Gitea Actions workflow at `.gitea/workflows/publish-pages.yml`. It builds the test app into `dist/` and uploads it to the shared F40 Pages R2 bucket using the layout:
|
||||
|
||||
```txt
|
||||
sites/{site-name}/releases/{git-sha}-{run-attempt}/
|
||||
sites/{site-name}/current.json
|
||||
```
|
||||
|
||||
By default, this repo uses:
|
||||
|
||||
```sh
|
||||
npm ci && npm run build:test-app
|
||||
```
|
||||
|
||||
The `build:test-app` script compiles the player, copies the generated `vr180player/` CDN assets, copies the simplified `test-pages/` app, and optionally copies `media/` if that local directory exists.
|
||||
|
||||
Required Gitea secrets:
|
||||
|
||||
- `F40_PAGES_R2_ACCOUNT_ID`
|
||||
- `F40_PAGES_R2_BUCKET`
|
||||
- `F40_PAGES_R2_ACCESS_KEY_ID`
|
||||
- `F40_PAGES_R2_SECRET_ACCESS_KEY`
|
||||
|
||||
Optional Gitea variables:
|
||||
|
||||
- `F40_PAGES_SITE_NAME`: defaults to the repository name.
|
||||
- `F40_PAGES_BUILD_COMMAND`: defaults to `npm ci && npm run build:test-app`.
|
||||
- `F40_PAGES_OUTPUT_DIR`: defaults to `dist`.
|
||||
|
||||
When the middleman router serves the current release at `https://pages.f-40.com/{site-name}/`, the generated player can also be used as a CDN from:
|
||||
|
||||
```html
|
||||
<script type="module" src="https://pages.f-40.com/VR-Web-Player/vr180player/vr180-player.js"></script>
|
||||
```
|
||||
|
||||
For stronger CDN caching, the router can expose immutable release URLs and use the `current.json` file only to resolve the latest release.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -60,6 +158,17 @@ The player source is TypeScript in `src/vr180player/`. Generated JavaScript file
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run build:test-app
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Upload to Cloudflare R2
|
||||
Copy `.env.r2.example` to `.env.r2`, then fill in your R2 account, bucket, and S3-compatible access key credentials.
|
||||
|
||||
```sh
|
||||
npm run upload:r2:dry-run
|
||||
npm run deploy:r2
|
||||
```
|
||||
|
||||
By default this uploads the built `vr180player/` folder under the `vr180player/` object prefix. Change `R2_SOURCE_DIR` or `R2_PREFIX` in `.env.r2` if you want a different source folder or CDN path.
|
||||
|
||||
31
index.html
31
index.html
@@ -2,32 +2,27 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>VR Web Player</title>
|
||||
<link rel="stylesheet" href="./vr180player/vr180-player.css" data-vr-web-player-stylesheet>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="0; url=./test-pages/">
|
||||
<title>VR Web Player Tests</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: normal;
|
||||
color: #151515;
|
||||
background: #f4f4f2;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 750px;
|
||||
margin: auto;
|
||||
a {
|
||||
color: inherit;
|
||||
font-weight: 650;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<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>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="./vr180player/vr180-player.js"></script>
|
||||
<p><a href="./test-pages/">Open VR Web Player test pages</a></p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BIN
media/3d_sbs.mp4
Normal file
BIN
media/3d_sbs.mp4
Normal file
Binary file not shown.
BIN
media/StormTrooper_VR.webp
Normal file
BIN
media/StormTrooper_VR.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 MiB |
10
package.json
10
package.json
@@ -5,10 +5,14 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm run build && vite --host 0.0.0.0",
|
||||
"build": "tsc",
|
||||
"build": "node scripts/clean-build-output.mjs && tsc && node scripts/copy-styles.mjs",
|
||||
"build:test-app": "npm run build && node scripts/build-test-app.mjs",
|
||||
"check": "tsc --noEmit",
|
||||
"test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.test.mjs tests/media-adapter.test.mjs",
|
||||
"preview": "npm run build && vite preview --host 127.0.0.1"
|
||||
"deploy:r2": "npm run build && npm run upload:r2",
|
||||
"test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.test.mjs tests/media-adapter.test.mjs tests/input-mode.test.mjs tests/icons.test.mjs tests/control-panel-timing.test.mjs tests/launcher.test.mjs",
|
||||
"preview": "npm run build && vite preview --host 127.0.0.1",
|
||||
"upload:r2": "node scripts/upload-r2.mjs",
|
||||
"upload:r2:dry-run": "node scripts/upload-r2.mjs --dry-run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
|
||||
58
scripts/build-test-app.mjs
Normal file
58
scripts/build-test-app.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
import { cp, mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const distDir = join(rootDir, 'dist');
|
||||
|
||||
await rm(distDir, { force: true, recursive: true });
|
||||
await mkdir(distDir, { recursive: true });
|
||||
|
||||
await copyRequired('index.html');
|
||||
await copyRequired('poster.jpg');
|
||||
await copyRequired('test-pages');
|
||||
await copyRequired('vr180player');
|
||||
await copyOptional('media');
|
||||
|
||||
await writeFile(
|
||||
join(distDir, '_headers'),
|
||||
[
|
||||
'/vr180player/*',
|
||||
' Cache-Control: public, max-age=3600',
|
||||
'',
|
||||
'/*',
|
||||
' Cache-Control: public, max-age=300',
|
||||
''
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
console.log(`Built test app in ${distDir}`);
|
||||
|
||||
async function copyRequired(relativePath) {
|
||||
const source = join(rootDir, relativePath);
|
||||
const target = join(distDir, relativePath);
|
||||
await cp(source, target, { recursive: true });
|
||||
}
|
||||
|
||||
async function copyOptional(relativePath) {
|
||||
const source = join(rootDir, relativePath);
|
||||
if (!await pathExists(source)) {
|
||||
console.warn(`Optional ${relativePath}/ directory not found; bundled sample media will not be included.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await cp(source, join(distDir, relativePath), { recursive: true });
|
||||
}
|
||||
|
||||
async function pathExists(path) {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
7
scripts/clean-build-output.mjs
Normal file
7
scripts/clean-build-output.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
await rm(join(rootDir, 'vr180player'), { force: true, recursive: true });
|
||||
16
scripts/copy-styles.mjs
Normal file
16
scripts/copy-styles.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import { copyFile, mkdir } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const styleCopies = [
|
||||
{
|
||||
from: join(rootDir, 'src', 'vr180player', 'styles', 'vr180-player.css'),
|
||||
to: join(rootDir, 'vr180player', 'vr180-player.css')
|
||||
}
|
||||
];
|
||||
|
||||
await Promise.all(styleCopies.map(async ({ from, to }) => {
|
||||
await mkdir(dirname(to), { recursive: true });
|
||||
await copyFile(from, to);
|
||||
}));
|
||||
284
scripts/upload-r2.mjs
Normal file
284
scripts/upload-r2.mjs
Normal file
@@ -0,0 +1,284 @@
|
||||
import { createHmac, createHash } from 'node:crypto';
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const args = new Set(process.argv.slice(2));
|
||||
|
||||
await loadEnvFile(join(rootDir, '.env.r2'));
|
||||
|
||||
const config = getConfig();
|
||||
await assertReadableSourceDir(config.sourceDir);
|
||||
const files = await listFiles(config.sourceDir);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.warn(`R2_UPLOAD: No files found in ${config.sourceDir}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const filePath of files) {
|
||||
const objectKey = getObjectKey(config.sourceDir, filePath, config.prefix);
|
||||
if (config.dryRun) {
|
||||
console.log(`R2_UPLOAD dry-run: ${filePath} -> r2://${config.bucket}/${objectKey}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await uploadFile(config, filePath, objectKey);
|
||||
console.log(`R2_UPLOAD: ${filePath} -> r2://${config.bucket}/${objectKey}`);
|
||||
}
|
||||
|
||||
console.log(`R2_UPLOAD: ${config.dryRun ? 'Checked' : 'Uploaded'} ${files.length} file(s).`);
|
||||
|
||||
function getConfig() {
|
||||
const accountId = requireEnv('R2_ACCOUNT_ID');
|
||||
const accessKeyId = requireEnv('R2_ACCESS_KEY_ID');
|
||||
const secretAccessKey = requireEnv('R2_SECRET_ACCESS_KEY');
|
||||
const bucket = requireEnv('R2_BUCKET');
|
||||
const endpoint = process.env.R2_ENDPOINT?.trim() ||
|
||||
`https://${accountId}.r2.cloudflarestorage.com`;
|
||||
|
||||
return {
|
||||
accessKeyId,
|
||||
bucket,
|
||||
cacheControl: process.env.R2_CACHE_CONTROL?.trim() || 'public, max-age=31536000, immutable',
|
||||
dryRun: args.has('--dry-run') || process.env.R2_DRY_RUN === 'true',
|
||||
endpoint: endpoint.replace(/\/+$/, ''),
|
||||
prefix: trimSlashes(process.env.R2_PREFIX ?? 'vr180player'),
|
||||
secretAccessKey,
|
||||
sourceDir: resolve(rootDir, process.env.R2_SOURCE_DIR?.trim() || 'vr180player')
|
||||
};
|
||||
}
|
||||
|
||||
async function assertReadableSourceDir(sourceDir) {
|
||||
try {
|
||||
const sourceStats = await stat(sourceDir);
|
||||
if (!sourceStats.isDirectory()) {
|
||||
console.error(`R2_UPLOAD: R2_SOURCE_DIR is not a directory: ${sourceDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
console.error([
|
||||
`R2_UPLOAD: Source directory not found: ${sourceDir}`,
|
||||
'R2_SOURCE_DIR is a local folder to upload, not the R2 bucket name.',
|
||||
'If "VR-180" is your bucket name, set R2_BUCKET=VR-180 and leave R2_SOURCE_DIR=vr180player.',
|
||||
'If vr180player/ is missing, run npm run build first.'
|
||||
].join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEnvFile(filePath) {
|
||||
let contents;
|
||||
try {
|
||||
contents = await readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const line of contents.split(/\r?\n/)) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const equalsIndex = trimmedLine.indexOf('=');
|
||||
if (equalsIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = trimmedLine.slice(0, equalsIndex).trim();
|
||||
const value = unquoteEnvValue(trimmedLine.slice(equalsIndex + 1).trim());
|
||||
if (key && process.env[key] === undefined) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`R2_UPLOAD: Missing required ${name}. Add it to .env.r2 or your environment.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function unquoteEnvValue(value) {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
async function listFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(entries.map(async (entry) => {
|
||||
const entryPath = join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return listFiles(entryPath);
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
return [entryPath];
|
||||
}
|
||||
|
||||
return [];
|
||||
}));
|
||||
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
function getObjectKey(sourceDir, filePath, prefix) {
|
||||
const relativePath = relative(sourceDir, filePath).split(sep).join('/');
|
||||
return [prefix, relativePath].filter(Boolean).join('/');
|
||||
}
|
||||
|
||||
async function uploadFile(config, filePath, objectKey) {
|
||||
const body = await readFile(filePath);
|
||||
const contentType = getContentType(filePath);
|
||||
const headers = signPutObject({
|
||||
body,
|
||||
cacheControl: config.cacheControl,
|
||||
contentType,
|
||||
endpoint: config.endpoint,
|
||||
accessKeyId: config.accessKeyId,
|
||||
bucket: config.bucket,
|
||||
objectKey,
|
||||
secretAccessKey: config.secretAccessKey
|
||||
});
|
||||
|
||||
const response = await fetch(getObjectUrl(config.endpoint, config.bucket, objectKey), {
|
||||
body,
|
||||
headers,
|
||||
method: 'PUT'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(`R2_UPLOAD: Failed to upload ${objectKey}: ${response.status} ${response.statusText}\n${responseText}`);
|
||||
}
|
||||
}
|
||||
|
||||
function signPutObject({
|
||||
body,
|
||||
cacheControl,
|
||||
contentType,
|
||||
endpoint,
|
||||
accessKeyId,
|
||||
bucket,
|
||||
objectKey,
|
||||
secretAccessKey
|
||||
}) {
|
||||
const url = new URL(getObjectUrl(endpoint, bucket, objectKey));
|
||||
const now = new Date();
|
||||
const amzDate = toAmzDate(now);
|
||||
const dateStamp = amzDate.slice(0, 8);
|
||||
const payloadHash = sha256Hex(body);
|
||||
const canonicalHeaders = [
|
||||
['cache-control', cacheControl],
|
||||
['content-type', contentType],
|
||||
['host', url.host],
|
||||
['x-amz-content-sha256', payloadHash],
|
||||
['x-amz-date', amzDate]
|
||||
];
|
||||
const signedHeaders = canonicalHeaders.map(([key]) => key).join(';');
|
||||
const canonicalRequest = [
|
||||
'PUT',
|
||||
url.pathname,
|
||||
'',
|
||||
canonicalHeaders.map(([key, value]) => `${key}:${value.trim()}\n`).join(''),
|
||||
signedHeaders,
|
||||
payloadHash
|
||||
].join('\n');
|
||||
const credentialScope = `${dateStamp}/auto/s3/aws4_request`;
|
||||
const stringToSign = [
|
||||
'AWS4-HMAC-SHA256',
|
||||
amzDate,
|
||||
credentialScope,
|
||||
sha256Hex(canonicalRequest)
|
||||
].join('\n');
|
||||
const signingKey = getSignatureKey(secretAccessKey, dateStamp, 'auto', 's3');
|
||||
const signature = hmacHex(signingKey, stringToSign);
|
||||
const headers = new Headers();
|
||||
|
||||
canonicalHeaders.forEach(([key, value]) => headers.set(key, value));
|
||||
headers.set(
|
||||
'authorization',
|
||||
`AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
|
||||
);
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function getObjectUrl(endpoint, bucket, objectKey) {
|
||||
return `${endpoint}/${encodePathSegment(bucket)}/${encodeObjectKey(objectKey)}`;
|
||||
}
|
||||
|
||||
function getSignatureKey(secretAccessKey, dateStamp, region, service) {
|
||||
const kDate = hmac(`AWS4${secretAccessKey}`, dateStamp);
|
||||
const kRegion = hmac(kDate, region);
|
||||
const kService = hmac(kRegion, service);
|
||||
return hmac(kService, 'aws4_request');
|
||||
}
|
||||
|
||||
function toAmzDate(date) {
|
||||
return date.toISOString().replace(/[:-]|\.\d{3}/g, '');
|
||||
}
|
||||
|
||||
function sha256Hex(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function hmac(key, value) {
|
||||
return createHmac('sha256', key).update(value).digest();
|
||||
}
|
||||
|
||||
function hmacHex(key, value) {
|
||||
return createHmac('sha256', key).update(value).digest('hex');
|
||||
}
|
||||
|
||||
function encodeObjectKey(key) {
|
||||
return key.split('/').map(encodePathSegment).join('/');
|
||||
}
|
||||
|
||||
function encodePathSegment(value) {
|
||||
return encodeURIComponent(value).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
}
|
||||
|
||||
function trimSlashes(value) {
|
||||
return value.trim().replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
function getContentType(filePath) {
|
||||
const extension = basename(filePath).toLowerCase().split('.').pop();
|
||||
const types = {
|
||||
css: 'text/css; charset=utf-8',
|
||||
gif: 'image/gif',
|
||||
html: 'text/html; charset=utf-8',
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
js: 'text/javascript; charset=utf-8',
|
||||
json: 'application/json; charset=utf-8',
|
||||
m4v: 'video/mp4',
|
||||
mp4: 'video/mp4',
|
||||
png: 'image/png',
|
||||
svg: 'image/svg+xml',
|
||||
webm: 'video/webm',
|
||||
webp: 'image/webp'
|
||||
};
|
||||
|
||||
return types[extension] || 'application/octet-stream';
|
||||
}
|
||||
@@ -1,68 +1,79 @@
|
||||
import {
|
||||
DEFAULT_HEAD_LOCK,
|
||||
DEFAULT_PROJECTION,
|
||||
PLAYER_SELECTOR,
|
||||
type HeadLockMode,
|
||||
type ProjectionMode,
|
||||
VALID_HEAD_LOCKS,
|
||||
VALID_PROJECTIONS
|
||||
} from './config.js';
|
||||
import { create2DControlPanel, createPlayButton, injectPlayerStyles } from './dom/dom.js';
|
||||
import { create2DControlPanel, createPlayButton } from './dom/dom.js';
|
||||
import { createMediaAdapter, type SupportedMediaAdapter } from './media/media-adapter.js';
|
||||
import { applyKnownImmersiveVrSupport } from './xr/xr-support.js';
|
||||
|
||||
export type BootstrapContext = {
|
||||
headLockMode: HeadLockMode;
|
||||
mediaAdapter: SupportedMediaAdapter;
|
||||
playButton: HTMLButtonElement;
|
||||
playerContainer: HTMLElement;
|
||||
projectionMode: ProjectionMode;
|
||||
};
|
||||
|
||||
export function bootstrapPlayer(playerBase: string, onReady: (context: BootstrapContext) => void): void {
|
||||
injectPlayerStyles(playerBase);
|
||||
type CreatePlayerContextOptions = {
|
||||
immersiveVrSupported?: boolean;
|
||||
};
|
||||
|
||||
onDocumentReady(() => {
|
||||
const containers = document.querySelectorAll<HTMLElement>(PLAYER_SELECTOR);
|
||||
export function createPlayerContext(playerContainer: HTMLElement, options: CreatePlayerContextOptions = {}): BootstrapContext | null {
|
||||
playerContainer.classList.add('vrwp');
|
||||
|
||||
if (containers.length === 0) {
|
||||
console.error(`VR_WEB_PLAYER_DOM: Expected exactly one ${PLAYER_SELECTOR} container, found none.`);
|
||||
return;
|
||||
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 null;
|
||||
}
|
||||
|
||||
const configuredHeadLock = (playerContainer.dataset.headLock || DEFAULT_HEAD_LOCK).trim().toLowerCase();
|
||||
if (!VALID_HEAD_LOCKS.has(configuredHeadLock as HeadLockMode)) {
|
||||
console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-head-lock="${configuredHeadLock}". Use "auto", "position", or "none".`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaAdapter = createMediaAdapter(playerContainer);
|
||||
if (!mediaAdapter) {
|
||||
console.error(`VR_WEB_PLAYER_DOM: Internal ${PLAYER_SELECTOR} container must contain exactly one video/img, or multiple img elements with data-carousel.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const playButton = createPlayButton();
|
||||
playerContainer.appendChild(playButton);
|
||||
playerContainer.appendChild(create2DControlPanel());
|
||||
playButton.disabled = true;
|
||||
|
||||
if (options.immersiveVrSupported !== undefined) {
|
||||
applyKnownImmersiveVrSupport(playButton, options.immersiveVrSupported);
|
||||
}
|
||||
|
||||
mediaAdapter.bindLoadState({
|
||||
onError: (event) => {
|
||||
console.error(`VR_WEB_PLAYER_MEDIA: Failed to load ${mediaAdapter.kind} media.`, event);
|
||||
playButton.disabled = true;
|
||||
},
|
||||
onReady: () => {
|
||||
playButton.disabled = false;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
mediaAdapter.load();
|
||||
|
||||
return {
|
||||
headLockMode: configuredHeadLock as HeadLockMode,
|
||||
mediaAdapter,
|
||||
playButton,
|
||||
playerContainer,
|
||||
projectionMode: configuredProjection as ProjectionMode
|
||||
};
|
||||
}
|
||||
|
||||
function onDocumentReady(callback: () => void): void {
|
||||
export function onDocumentReady(callback: () => void): void {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', callback, { once: true });
|
||||
return;
|
||||
@@ -70,30 +81,3 @@ function onDocumentReady(callback: () => void): void {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
export const PLAYER_SELECTOR = '[data-vr-web-player]';
|
||||
export const LAUNCHER_SELECTOR = '[data-vr-web-launcher]';
|
||||
|
||||
export type ProjectionMode = 'vr180' | 'plane';
|
||||
export type HeadLockMode = 'auto' | 'position' | 'none';
|
||||
export type LauncherMediaType = 'image' | 'video';
|
||||
|
||||
export const DEFAULT_PROJECTION: ProjectionMode = 'vr180';
|
||||
export const DEFAULT_HEAD_LOCK: HeadLockMode = 'auto';
|
||||
export const VALID_PROJECTIONS = new Set<ProjectionMode>(['vr180', 'plane']);
|
||||
export const VALID_HEAD_LOCKS = new Set<HeadLockMode>(['auto', 'position', 'none']);
|
||||
export const VALID_LAUNCHER_MEDIA_TYPES = new Set<LauncherMediaType>(['image', 'video']);
|
||||
|
||||
export const PLANE_WIDTH = 3.2;
|
||||
export const PLANE_HEIGHT = PLANE_WIDTH * (9 / 16);
|
||||
|
||||
@@ -21,7 +21,7 @@ export function createPlayButton(): HTMLButtonElement {
|
||||
const playButton = document.createElement('button');
|
||||
playButton.type = 'button';
|
||||
playButton.className = 'vrwp-play-button';
|
||||
playButton.setAttribute('aria-label', 'Play video');
|
||||
playButton.setAttribute('aria-label', 'Open media');
|
||||
playButton.appendChild(createLucideIcon('circle-play'));
|
||||
|
||||
return playButton;
|
||||
@@ -82,9 +82,12 @@ export function create2DControlPanel(): HTMLDivElement {
|
||||
nav.appendChild(forwardBtn);
|
||||
|
||||
const muteBtn = createControlButton('vrwp-mute', 'Toggle mute', 'volume-2');
|
||||
const loopBtn = createControlButton('vrwp-loop', 'Loop video', 'repeat');
|
||||
loopBtn.setAttribute('aria-pressed', 'false');
|
||||
|
||||
controls.appendChild(fullscreenBtn);
|
||||
controls.appendChild(nav);
|
||||
controls.appendChild(loopBtn);
|
||||
controls.appendChild(muteBtn);
|
||||
|
||||
panel.appendChild(status);
|
||||
|
||||
76
src/vr180player/dom/fallback-modal.ts
Normal file
76
src/vr180player/dom/fallback-modal.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { createLucideIcon } from './icons.js';
|
||||
|
||||
export class FallbackModal {
|
||||
private readonly content: HTMLElement;
|
||||
private readonly onClose: () => void;
|
||||
private readonly root: HTMLElement;
|
||||
|
||||
constructor(onClose: () => void) {
|
||||
this.onClose = onClose;
|
||||
this.root = document.createElement('div');
|
||||
this.root.className = 'vrwp-modal';
|
||||
this.root.hidden = true;
|
||||
this.root.setAttribute('role', 'dialog');
|
||||
this.root.setAttribute('aria-modal', 'true');
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'vrwp-modal-dialog';
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.type = 'button';
|
||||
closeButton.className = 'vrwp-modal-close';
|
||||
closeButton.setAttribute('aria-label', 'Close fallback player');
|
||||
closeButton.appendChild(createLucideIcon('x'));
|
||||
closeButton.addEventListener('click', () => this.close());
|
||||
|
||||
this.content = document.createElement('div');
|
||||
this.content.className = 'vrwp-modal-content';
|
||||
|
||||
dialog.appendChild(closeButton);
|
||||
dialog.appendChild(this.content);
|
||||
this.root.appendChild(dialog);
|
||||
this.root.addEventListener('click', (event) => {
|
||||
if (event.target === this.root) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get isOpen(): boolean {
|
||||
return !this.root.hidden;
|
||||
}
|
||||
|
||||
clearContent(): void {
|
||||
this.content.replaceChildren();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (!this.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.root.hidden = true;
|
||||
document.removeEventListener('keydown', this.onKeyDown);
|
||||
this.onClose();
|
||||
}
|
||||
|
||||
open(): void {
|
||||
if (!this.root.isConnected) {
|
||||
document.body.appendChild(this.root);
|
||||
}
|
||||
|
||||
this.root.hidden = false;
|
||||
document.addEventListener('keydown', this.onKeyDown);
|
||||
this.root.querySelector<HTMLElement>('.vrwp-modal-close')?.focus();
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement): void {
|
||||
this.content.replaceChildren(element);
|
||||
}
|
||||
|
||||
private readonly onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,11 +3,16 @@ export type LucideIconName =
|
||||
| 'play'
|
||||
| 'pause'
|
||||
| 'maximize'
|
||||
| 'arrow-left'
|
||||
| 'chevron-left'
|
||||
| 'chevron-right'
|
||||
| 'rotate-ccw'
|
||||
| 'rotate-cw'
|
||||
| 'repeat'
|
||||
| 'volume-2'
|
||||
| 'volume-x'
|
||||
| 'log-out';
|
||||
| 'log-out'
|
||||
| 'x';
|
||||
|
||||
type IconAttrs = Record<string, string>;
|
||||
type IconNode = readonly [tagName: string, attrs: IconAttrs];
|
||||
@@ -32,6 +37,16 @@ const ICONS: Record<LucideIconName, readonly IconNode[]> = {
|
||||
['path', { d: 'M3 16v3a2 2 0 0 0 2 2h3' }],
|
||||
['path', { d: 'M16 21h3a2 2 0 0 0 2-2v-3' }]
|
||||
],
|
||||
'arrow-left': [
|
||||
['path', { d: 'm12 19-7-7 7-7' }],
|
||||
['path', { d: 'M19 12H5' }]
|
||||
],
|
||||
'chevron-left': [
|
||||
['path', { d: 'm15 18-6-6 6-6' }]
|
||||
],
|
||||
'chevron-right': [
|
||||
['path', { d: 'm9 18 6-6-6-6' }]
|
||||
],
|
||||
'rotate-ccw': [
|
||||
['path', { d: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' }],
|
||||
['path', { d: 'M3 3v5h5' }]
|
||||
@@ -40,6 +55,12 @@ const ICONS: Record<LucideIconName, readonly IconNode[]> = {
|
||||
['path', { d: 'M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8' }],
|
||||
['path', { d: 'M21 3v5h-5' }]
|
||||
],
|
||||
repeat: [
|
||||
['path', { d: 'm17 2 4 4-4 4' }],
|
||||
['path', { d: 'M3 11v-1a4 4 0 0 1 4-4h14' }],
|
||||
['path', { d: 'm7 22-4-4 4-4' }],
|
||||
['path', { d: 'M21 13v1a4 4 0 0 1-4 4H3' }]
|
||||
],
|
||||
'volume-2': [
|
||||
['path', { d: 'M11 4.702a1 1 0 0 0-1.664-.747L5.5 7.5H4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h1.5l3.836 3.545A1 1 0 0 0 11 19.298z' }],
|
||||
['path', { d: 'M16 9a5 5 0 0 1 0 6' }],
|
||||
@@ -54,6 +75,10 @@ const ICONS: Record<LucideIconName, readonly IconNode[]> = {
|
||||
['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' }]
|
||||
],
|
||||
x: [
|
||||
['path', { d: 'M18 6 6 18' }],
|
||||
['path', { d: 'm6 6 12 12' }]
|
||||
]
|
||||
};
|
||||
|
||||
@@ -140,7 +165,15 @@ function drawIconNode(ctx: CanvasRenderingContext2D, tagName: string, attrs: Ico
|
||||
}
|
||||
|
||||
function drawPoints(ctx: CanvasRenderingContext2D, pointsAttr: string, closePath: boolean): void {
|
||||
const points = pointsAttr.trim().split(/\s+/).map((pair) => pair.split(',').map(Number));
|
||||
const coordinates = pointsAttr.trim().split(/[\s,]+/).map(Number);
|
||||
const points = [];
|
||||
for (let index = 0; index < coordinates.length - 1; index += 2) {
|
||||
const x = coordinates[index];
|
||||
const y = coordinates[index + 1];
|
||||
if (Number.isFinite(x) && Number.isFinite(y)) {
|
||||
points.push([x, y]);
|
||||
}
|
||||
}
|
||||
if (points.length === 0) return;
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
import { setLucideIcon } from './icons.js';
|
||||
import { formatTime } from '../utils/time.js';
|
||||
import type { MediaCapabilities } from '../media/media-adapter.js';
|
||||
import { DEFAULT_2D_CONTROL_AUTO_HIDE_DELAY_MS } from '../utils/control-panel-timing.js';
|
||||
|
||||
type TwoDControlPanelCallbacks = {
|
||||
getIsLooping: () => boolean;
|
||||
onForward: () => void;
|
||||
onMute: () => void;
|
||||
onPlayPause: () => void;
|
||||
onRewind: () => void;
|
||||
onSeek: (progress: number) => void;
|
||||
onToggleLoop: () => boolean;
|
||||
};
|
||||
|
||||
type TwoDControlPanelOptions = {
|
||||
callbacks: TwoDControlPanelCallbacks;
|
||||
fullscreenTarget: HTMLElement;
|
||||
getAutoHideDelayMs?: () => number;
|
||||
getIsActive: () => boolean;
|
||||
mediaCapabilities: MediaCapabilities;
|
||||
playerContainer: HTMLElement;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const CONTROL_PANEL_HIDE_DELAY = 3000;
|
||||
|
||||
export class TwoDControlPanel {
|
||||
private readonly callbacks: TwoDControlPanelCallbacks;
|
||||
private readonly fullscreenTarget: HTMLElement;
|
||||
private readonly getAutoHideDelayMs: () => number;
|
||||
private readonly getIsActive: () => boolean;
|
||||
private readonly playerContainer: HTMLElement;
|
||||
private controlPanel: HTMLElement | null;
|
||||
@@ -30,12 +35,18 @@ export class TwoDControlPanel {
|
||||
private playedBar: HTMLElement | null;
|
||||
private progressBar: HTMLElement | null;
|
||||
private totalTimeDisplay: HTMLElement | null;
|
||||
private backButton: HTMLButtonElement | null;
|
||||
private forwardButton: HTMLButtonElement | null;
|
||||
private loopButton: HTMLButtonElement | null;
|
||||
private playButton: HTMLButtonElement | null;
|
||||
private muteButton: HTMLButtonElement | null;
|
||||
private navControls: HTMLElement | null;
|
||||
private progressControls: HTMLElement | null;
|
||||
|
||||
constructor({ callbacks, fullscreenTarget, getIsActive, playerContainer, title }: TwoDControlPanelOptions) {
|
||||
constructor({ callbacks, fullscreenTarget, getAutoHideDelayMs, getIsActive, mediaCapabilities, playerContainer, title }: TwoDControlPanelOptions) {
|
||||
this.callbacks = callbacks;
|
||||
this.fullscreenTarget = fullscreenTarget;
|
||||
this.getAutoHideDelayMs = getAutoHideDelayMs ?? (() => DEFAULT_2D_CONTROL_AUTO_HIDE_DELAY_MS);
|
||||
this.getIsActive = getIsActive;
|
||||
this.playerContainer = playerContainer;
|
||||
|
||||
@@ -43,10 +54,15 @@ export class TwoDControlPanel {
|
||||
const videoTitle = playerContainer.querySelector<HTMLElement>('.vrwp-video-title');
|
||||
this.currentTimeDisplay = playerContainer.querySelector('.vrwp-current-time');
|
||||
this.totalTimeDisplay = playerContainer.querySelector('.vrwp-total-time');
|
||||
this.progressControls = playerContainer.querySelector('.vrwp-progress');
|
||||
this.progressBar = playerContainer.querySelector('.vrwp-bar');
|
||||
this.playedBar = playerContainer.querySelector('.vrwp-played');
|
||||
this.backButton = playerContainer.querySelector('.vrwp-back');
|
||||
this.forwardButton = playerContainer.querySelector('.vrwp-forward');
|
||||
this.loopButton = playerContainer.querySelector('.vrwp-loop');
|
||||
this.playButton = playerContainer.querySelector('.vrwp-play-toggle');
|
||||
this.muteButton = playerContainer.querySelector('.vrwp-mute');
|
||||
this.navControls = playerContainer.querySelector('.vrwp-nav');
|
||||
|
||||
if (!this.controlPanel) {
|
||||
console.error('VR_WEB_PLAYER_DOM: 2D control panel was not found.');
|
||||
@@ -57,7 +73,9 @@ export class TwoDControlPanel {
|
||||
videoTitle.textContent = title;
|
||||
}
|
||||
|
||||
this.bindControls(playerContainer);
|
||||
this.applyCapabilities(mediaCapabilities);
|
||||
this.bindControls(playerContainer, mediaCapabilities);
|
||||
this.updateLoopButton(this.callbacks.getIsLooping());
|
||||
}
|
||||
|
||||
show(): void {
|
||||
@@ -65,7 +83,7 @@ export class TwoDControlPanel {
|
||||
|
||||
this.clearHideTimeout();
|
||||
this.controlPanel.classList.add('visible');
|
||||
this.hideTimeout = window.setTimeout(() => this.hide(), CONTROL_PANEL_HIDE_DELAY);
|
||||
this.hideTimeout = window.setTimeout(() => this.hide(), this.getAutoHideDelayMs());
|
||||
}
|
||||
|
||||
showPersistent(): void {
|
||||
@@ -125,6 +143,16 @@ export class TwoDControlPanel {
|
||||
this.playButton.classList.add('playing');
|
||||
setLucideIcon(this.playButton, 'pause');
|
||||
}
|
||||
|
||||
this.refreshAutoHideIfVisible();
|
||||
}
|
||||
|
||||
updateLoopButton(isLooping: boolean): void {
|
||||
if (!this.loopButton) return;
|
||||
|
||||
this.loopButton.classList.toggle('active', isLooping);
|
||||
this.loopButton.setAttribute('aria-pressed', String(isLooping));
|
||||
this.loopButton.setAttribute('aria-label', isLooping ? 'Disable video loop' : 'Loop video');
|
||||
}
|
||||
|
||||
updateTime(currentTime: number, duration: number): void {
|
||||
@@ -144,38 +172,77 @@ export class TwoDControlPanel {
|
||||
}
|
||||
}
|
||||
|
||||
private bindControls(playerContainer: HTMLElement): void {
|
||||
private applyCapabilities(mediaCapabilities: MediaCapabilities): void {
|
||||
if (!mediaCapabilities.timeline && this.progressControls) {
|
||||
this.progressControls.hidden = true;
|
||||
}
|
||||
|
||||
if (!mediaCapabilities.navigation && this.navControls) {
|
||||
this.navControls.hidden = true;
|
||||
}
|
||||
|
||||
if (!mediaCapabilities.playback && this.playButton) {
|
||||
this.playButton.hidden = true;
|
||||
}
|
||||
|
||||
if (!mediaCapabilities.playback && this.loopButton) {
|
||||
this.loopButton.hidden = true;
|
||||
}
|
||||
|
||||
if (mediaCapabilities.carousel) {
|
||||
this.configureCarouselNavigation();
|
||||
}
|
||||
|
||||
if (!mediaCapabilities.audio && this.muteButton) {
|
||||
this.muteButton.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
private bindControls(playerContainer: HTMLElement, mediaCapabilities: MediaCapabilities): void {
|
||||
playerContainer.querySelector('.vrwp-fullscreen')?.addEventListener('click', () => {
|
||||
this.toggleFullscreen();
|
||||
});
|
||||
|
||||
playerContainer.querySelector('.vrwp-back')?.addEventListener('click', () => {
|
||||
this.callbacks.onRewind();
|
||||
this.show();
|
||||
});
|
||||
if (mediaCapabilities.navigation) {
|
||||
this.backButton?.addEventListener('click', () => {
|
||||
this.callbacks.onRewind();
|
||||
this.show();
|
||||
});
|
||||
|
||||
this.playButton?.addEventListener('click', () => {
|
||||
this.callbacks.onPlayPause();
|
||||
this.show();
|
||||
});
|
||||
this.forwardButton?.addEventListener('click', () => {
|
||||
this.callbacks.onForward();
|
||||
this.show();
|
||||
});
|
||||
}
|
||||
|
||||
playerContainer.querySelector('.vrwp-forward')?.addEventListener('click', () => {
|
||||
this.callbacks.onForward();
|
||||
this.show();
|
||||
});
|
||||
if (mediaCapabilities.playback) {
|
||||
this.playButton?.addEventListener('click', () => {
|
||||
this.callbacks.onPlayPause();
|
||||
this.show();
|
||||
});
|
||||
|
||||
this.muteButton?.addEventListener('click', () => {
|
||||
this.callbacks.onMute();
|
||||
this.show();
|
||||
});
|
||||
this.loopButton?.addEventListener('click', () => {
|
||||
this.updateLoopButton(this.callbacks.onToggleLoop());
|
||||
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();
|
||||
});
|
||||
if (mediaCapabilities.audio) {
|
||||
this.muteButton?.addEventListener('click', () => {
|
||||
this.callbacks.onMute();
|
||||
this.show();
|
||||
});
|
||||
}
|
||||
|
||||
if (mediaCapabilities.timeline) {
|
||||
this.progressBar?.addEventListener('click', (event) => {
|
||||
const rect = this.progressBar?.getBoundingClientRect();
|
||||
if (rect && rect.width > 0) {
|
||||
this.callbacks.onSeek((event.clientX - rect.left) / rect.width);
|
||||
}
|
||||
this.show();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private clearHideTimeout(): void {
|
||||
@@ -185,6 +252,28 @@ export class TwoDControlPanel {
|
||||
}
|
||||
}
|
||||
|
||||
private refreshAutoHideIfVisible(): void {
|
||||
if (!this.controlPanel?.classList.contains('visible')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.show();
|
||||
}
|
||||
|
||||
private configureCarouselNavigation(): void {
|
||||
if (this.backButton) {
|
||||
this.backButton.setAttribute('aria-label', 'Previous image');
|
||||
setLucideIcon(this.backButton, 'chevron-left');
|
||||
this.backButton.querySelector('.vrwp-skip-label')?.remove();
|
||||
}
|
||||
|
||||
if (this.forwardButton) {
|
||||
this.forwardButton.setAttribute('aria-label', 'Next image');
|
||||
setLucideIcon(this.forwardButton, 'chevron-right');
|
||||
this.forwardButton.querySelector('.vrwp-skip-label')?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private toggleFullscreen(): void {
|
||||
if (!document.fullscreenElement) {
|
||||
this.fullscreenTarget.requestFullscreen().catch((err) => {
|
||||
|
||||
123
src/vr180player/launcher/launcher-bootstrap.ts
Normal file
123
src/vr180player/launcher/launcher-bootstrap.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { LAUNCHER_SELECTOR } from '../config.js';
|
||||
import { FallbackModal } from '../dom/fallback-modal.js';
|
||||
import { getImmersiveVrSupport } from '../xr/xr-support.js';
|
||||
import {
|
||||
createPlayerContainerFromLauncherConfig,
|
||||
getLauncherAction,
|
||||
readLauncherMediaConfig
|
||||
} from './launcher-config.js';
|
||||
|
||||
export type LauncherPlayerSession = {
|
||||
enterImmersive: () => Promise<boolean>;
|
||||
showFallback: () => void;
|
||||
stopFallback: () => void;
|
||||
};
|
||||
|
||||
type SetupLaunchersOptions = {
|
||||
createSession: (playerContainer: HTMLElement, immersiveVrSupported: boolean) => LauncherPlayerSession | null;
|
||||
};
|
||||
|
||||
type ActiveLauncherSession = {
|
||||
container: HTMLElement;
|
||||
session: LauncherPlayerSession;
|
||||
};
|
||||
|
||||
export function setupLauncherButtons({ createSession }: SetupLaunchersOptions): boolean {
|
||||
const launchers = Array.from(document.querySelectorAll<HTMLElement>(LAUNCHER_SELECTOR));
|
||||
if (launchers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let activeSession: ActiveLauncherSession | undefined;
|
||||
let immersiveVrSupported: boolean | null = null;
|
||||
const hiddenHost = createHiddenLauncherHost();
|
||||
const fallbackModal = new FallbackModal(() => {
|
||||
clearActiveSession();
|
||||
fallbackModal.clearContent();
|
||||
});
|
||||
|
||||
getImmersiveVrSupport().then((supported) => {
|
||||
immersiveVrSupported = supported;
|
||||
for (const launcher of launchers) {
|
||||
launcher.dataset.xrSupported = String(supported);
|
||||
}
|
||||
});
|
||||
|
||||
for (const launcher of launchers) {
|
||||
launcher.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
void handleLauncherClick(launcher);
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
async function handleLauncherClick(launcher: HTMLElement): Promise<void> {
|
||||
if (fallbackModal.isOpen) {
|
||||
fallbackModal.close();
|
||||
} else {
|
||||
clearActiveSession();
|
||||
}
|
||||
|
||||
const config = readLauncherMediaConfig(launcher);
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldAttemptImmersive = immersiveVrSupported === true || (immersiveVrSupported === null && Boolean(navigator.xr));
|
||||
const action = getLauncherAction(shouldAttemptImmersive);
|
||||
const playerContainer = createPlayerContainerFromLauncherConfig(config);
|
||||
|
||||
if (action === 'immersive') {
|
||||
hiddenHost.appendChild(playerContainer);
|
||||
const session = createSession(playerContainer, true);
|
||||
if (!session) {
|
||||
playerContainer.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
activeSession = { container: playerContainer, session };
|
||||
const startedImmersive = await session.enterImmersive();
|
||||
if (!startedImmersive) {
|
||||
fallbackModal.setContent(playerContainer);
|
||||
fallbackModal.open();
|
||||
session.showFallback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fallbackModal.setContent(playerContainer);
|
||||
fallbackModal.open();
|
||||
const session = createSession(playerContainer, false);
|
||||
if (!session) {
|
||||
fallbackModal.close();
|
||||
return;
|
||||
}
|
||||
|
||||
activeSession = { container: playerContainer, session };
|
||||
session.showFallback();
|
||||
}
|
||||
|
||||
function clearActiveSession(): void {
|
||||
if (!activeSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeSession.session.stopFallback();
|
||||
activeSession.container.remove();
|
||||
activeSession = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function createHiddenLauncherHost(): HTMLElement {
|
||||
const existingHost = document.querySelector<HTMLElement>('.vrwp-launcher-host');
|
||||
if (existingHost) {
|
||||
return existingHost;
|
||||
}
|
||||
|
||||
const host = document.createElement('div');
|
||||
host.className = 'vrwp-launcher-host';
|
||||
host.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(host);
|
||||
return host;
|
||||
}
|
||||
223
src/vr180player/launcher/launcher-config.ts
Normal file
223
src/vr180player/launcher/launcher-config.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
DEFAULT_HEAD_LOCK,
|
||||
DEFAULT_PROJECTION,
|
||||
type HeadLockMode,
|
||||
type LauncherMediaType,
|
||||
type ProjectionMode,
|
||||
VALID_HEAD_LOCKS,
|
||||
VALID_LAUNCHER_MEDIA_TYPES,
|
||||
VALID_PROJECTIONS
|
||||
} from '../config.js';
|
||||
|
||||
export type LauncherAction = 'fallback-modal' | 'immersive';
|
||||
|
||||
export type LauncherMediaConfig = {
|
||||
carousel: boolean;
|
||||
crossOrigin: string;
|
||||
headLockMode: HeadLockMode;
|
||||
mediaType: LauncherMediaType;
|
||||
poster: string;
|
||||
preload: string;
|
||||
projectionMode: ProjectionMode;
|
||||
src: string;
|
||||
srcs: string[];
|
||||
title: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['avif', 'gif', 'jpeg', 'jpg', 'png', 'webp']);
|
||||
const VIDEO_EXTENSIONS = new Set(['m4v', 'mov', 'mp4', 'ogv', 'webm']);
|
||||
|
||||
export function getLauncherAction(immersiveVrSupported: boolean): LauncherAction {
|
||||
return immersiveVrSupported ? 'immersive' : 'fallback-modal';
|
||||
}
|
||||
|
||||
export function readLauncherMediaConfig(launcher: HTMLElement): LauncherMediaConfig | null {
|
||||
const srcs = parseSourceList(launcher.dataset.src || '');
|
||||
if (srcs.length === 0) {
|
||||
console.error('VR_WEB_PLAYER_LAUNCHER: data-src is required on [data-vr-web-launcher].');
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCarousel = isCarouselEnabled(launcher);
|
||||
|
||||
const projectionMode = readProjectionMode(launcher.dataset.projection);
|
||||
if (!projectionMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headLockMode = readHeadLockMode(launcher.dataset.headLock);
|
||||
if (!headLockMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaType = readMediaType(launcher.dataset.mediaType, srcs[0]);
|
||||
if (!mediaType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isCarousel && mediaType !== 'image') {
|
||||
console.error('VR_WEB_PLAYER_LAUNCHER: data-carousel currently supports image launchers only.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isCarousel && srcs.length < 2) {
|
||||
console.error('VR_WEB_PLAYER_LAUNCHER: data-carousel requires at least two comma-separated data-src values.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isCarousel && srcs.length > 1) {
|
||||
console.error('VR_WEB_PLAYER_LAUNCHER: Multiple data-src values require data-carousel.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
carousel: isCarousel,
|
||||
crossOrigin: (launcher.dataset.crossorigin || '').trim(),
|
||||
headLockMode,
|
||||
mediaType,
|
||||
poster: (launcher.dataset.poster || '').trim(),
|
||||
preload: (launcher.dataset.preload || 'metadata').trim(),
|
||||
projectionMode,
|
||||
src: srcs[0],
|
||||
srcs,
|
||||
title: (launcher.dataset.title || launcher.getAttribute('aria-label') || '').trim(),
|
||||
type: (launcher.dataset.type || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
export function createPlayerContainerFromLauncherConfig(config: LauncherMediaConfig): HTMLElement {
|
||||
const playerContainer = document.createElement('div');
|
||||
playerContainer.dataset.vrWebPlayer = '';
|
||||
playerContainer.dataset.projection = config.projectionMode;
|
||||
playerContainer.dataset.headLock = config.headLockMode;
|
||||
playerContainer.className = 'vrwp-launcher-player';
|
||||
|
||||
if (config.carousel) {
|
||||
playerContainer.dataset.carousel = '';
|
||||
for (const [index, src] of config.srcs.entries()) {
|
||||
playerContainer.appendChild(createImageElement(config, src, index));
|
||||
}
|
||||
return playerContainer;
|
||||
}
|
||||
|
||||
if (config.mediaType === 'video') {
|
||||
playerContainer.appendChild(createVideoElement(config));
|
||||
return playerContainer;
|
||||
}
|
||||
|
||||
playerContainer.appendChild(createImageElement(config, config.src, 0));
|
||||
return playerContainer;
|
||||
}
|
||||
|
||||
export function inferLauncherMediaType(src: string): LauncherMediaType | null {
|
||||
const extension = getExtension(src);
|
||||
if (!extension) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (IMAGE_EXTENSIONS.has(extension)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (VIDEO_EXTENSIONS.has(extension)) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createVideoElement(config: LauncherMediaConfig): HTMLVideoElement {
|
||||
const video = document.createElement('video');
|
||||
video.title = config.title;
|
||||
video.playsInline = true;
|
||||
video.preload = config.preload as HTMLVideoElement['preload'];
|
||||
|
||||
if (config.crossOrigin) {
|
||||
video.crossOrigin = config.crossOrigin;
|
||||
}
|
||||
|
||||
if (config.poster) {
|
||||
video.poster = config.poster;
|
||||
}
|
||||
|
||||
const source = document.createElement('source');
|
||||
source.src = config.src;
|
||||
if (config.type) {
|
||||
source.type = config.type;
|
||||
}
|
||||
video.appendChild(source);
|
||||
|
||||
return video;
|
||||
}
|
||||
|
||||
function createImageElement(config: LauncherMediaConfig, src: string, index: number): HTMLImageElement {
|
||||
const image = document.createElement('img');
|
||||
image.src = src;
|
||||
image.alt = config.carousel ? `${config.title || 'Image'} ${index + 1}` : config.title;
|
||||
image.title = config.carousel ? `${config.title || 'Image'} ${index + 1}` : config.title;
|
||||
|
||||
if (config.crossOrigin) {
|
||||
image.crossOrigin = config.crossOrigin;
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
function isCarouselEnabled(launcher: HTMLElement): boolean {
|
||||
const carouselValue = launcher.dataset.carousel;
|
||||
return carouselValue !== undefined && carouselValue.toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
function parseSourceList(value: string): string[] {
|
||||
return value
|
||||
.split(',')
|
||||
.map((src) => src.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function readProjectionMode(value: string | undefined): ProjectionMode | null {
|
||||
const projectionMode = (value || DEFAULT_PROJECTION).trim().toLowerCase();
|
||||
if (!VALID_PROJECTIONS.has(projectionMode as ProjectionMode)) {
|
||||
console.error(`VR_WEB_PLAYER_LAUNCHER: Unsupported data-projection="${projectionMode}". Use "vr180" or "plane".`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return projectionMode as ProjectionMode;
|
||||
}
|
||||
|
||||
function readHeadLockMode(value: string | undefined): HeadLockMode | null {
|
||||
const headLockMode = (value || DEFAULT_HEAD_LOCK).trim().toLowerCase();
|
||||
if (!VALID_HEAD_LOCKS.has(headLockMode as HeadLockMode)) {
|
||||
console.error(`VR_WEB_PLAYER_LAUNCHER: Unsupported data-head-lock="${headLockMode}". Use "auto", "position", or "none".`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return headLockMode as HeadLockMode;
|
||||
}
|
||||
|
||||
function readMediaType(value: string | undefined, src: string): LauncherMediaType | null {
|
||||
const configuredType = (value || '').trim().toLowerCase();
|
||||
if (configuredType) {
|
||||
if (!VALID_LAUNCHER_MEDIA_TYPES.has(configuredType as LauncherMediaType)) {
|
||||
console.error(`VR_WEB_PLAYER_LAUNCHER: Unsupported data-media-type="${configuredType}". Use "image" or "video".`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return configuredType as LauncherMediaType;
|
||||
}
|
||||
|
||||
const inferredType = inferLauncherMediaType(src);
|
||||
if (!inferredType) {
|
||||
console.error('VR_WEB_PLAYER_LAUNCHER: Could not infer media type from data-src. Add data-media-type="image" or data-media-type="video".');
|
||||
return null;
|
||||
}
|
||||
|
||||
return inferredType;
|
||||
}
|
||||
|
||||
function getExtension(src: string): string {
|
||||
const cleanSrc = src.split(/[?#]/, 1)[0].toLowerCase();
|
||||
const extension = cleanSrc.slice(cleanSrc.lastIndexOf('.') + 1);
|
||||
return extension === cleanSrc ? '' : extension;
|
||||
}
|
||||
113
src/vr180player/media/image-carousel-media-adapter.ts
Normal file
113
src/vr180player/media/image-carousel-media-adapter.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type {
|
||||
MediaAdapter,
|
||||
MediaCapabilities,
|
||||
MediaLoadCallbacks
|
||||
} from './media-adapter.js';
|
||||
import { getFilenameTitle } from './media-title.js';
|
||||
|
||||
const IMAGE_CAROUSEL_CAPABILITIES: MediaCapabilities = {
|
||||
audio: false,
|
||||
carousel: true,
|
||||
dynamicTexture: false,
|
||||
navigation: true,
|
||||
playback: false,
|
||||
timeline: false
|
||||
};
|
||||
|
||||
export class ImageCarouselMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLImageElement> {
|
||||
readonly capabilities = IMAGE_CAROUSEL_CAPABILITIES;
|
||||
readonly kind = 'image' as const;
|
||||
private currentIndex = 0;
|
||||
private isHidden = false;
|
||||
|
||||
constructor(private readonly images: HTMLImageElement[]) {
|
||||
this.images.forEach((image) => {
|
||||
image.classList.add('vrwp-media', 'vrwp-image', 'vrwp-carousel-image');
|
||||
});
|
||||
this.applyVisibility();
|
||||
}
|
||||
|
||||
get element(): HTMLImageElement {
|
||||
return this.images[this.currentIndex];
|
||||
}
|
||||
|
||||
get textureSource(): HTMLImageElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
return this.element.getAttribute('title') ||
|
||||
this.element.getAttribute('alt') ||
|
||||
getFilenameTitle(this.element.currentSrc || this.element.src) ||
|
||||
`Image ${this.currentIndex + 1}`;
|
||||
}
|
||||
|
||||
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
|
||||
let hasReportedReady = false;
|
||||
const reportReadyIfAllLoaded = () => {
|
||||
if (hasReportedReady || !this.areAllImagesReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasReportedReady = true;
|
||||
onReady();
|
||||
};
|
||||
|
||||
this.images.forEach((image) => {
|
||||
image.addEventListener('load', reportReadyIfAllLoaded);
|
||||
image.addEventListener('error', onError);
|
||||
});
|
||||
|
||||
if (this.areAllImagesReady()) {
|
||||
queueMicrotask(reportReadyIfAllLoaded);
|
||||
}
|
||||
}
|
||||
|
||||
hideElement(): void {
|
||||
this.isHidden = true;
|
||||
this.applyVisibility();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.images.forEach((image) => {
|
||||
image.loading = 'eager';
|
||||
});
|
||||
}
|
||||
|
||||
next(): boolean {
|
||||
return this.selectRelative(1);
|
||||
}
|
||||
|
||||
previous(): boolean {
|
||||
return this.selectRelative(-1);
|
||||
}
|
||||
|
||||
shouldUpdateTexture(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
showElement(): void {
|
||||
this.isHidden = false;
|
||||
this.applyVisibility();
|
||||
}
|
||||
|
||||
private selectRelative(offset: number): boolean {
|
||||
if (this.images.length <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.currentIndex = (this.currentIndex + offset + this.images.length) % this.images.length;
|
||||
this.applyVisibility();
|
||||
return true;
|
||||
}
|
||||
|
||||
private applyVisibility(): void {
|
||||
this.images.forEach((image, index) => {
|
||||
image.style.display = this.isHidden || index !== this.currentIndex ? 'none' : '';
|
||||
});
|
||||
}
|
||||
|
||||
private areAllImagesReady(): boolean {
|
||||
return this.images.every((image) => image.complete && image.naturalWidth > 0);
|
||||
}
|
||||
}
|
||||
66
src/vr180player/media/image-media-adapter.ts
Normal file
66
src/vr180player/media/image-media-adapter.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
MediaAdapter,
|
||||
MediaCapabilities,
|
||||
MediaLoadCallbacks
|
||||
} from './media-adapter.js';
|
||||
import { getFilenameTitle } from './media-title.js';
|
||||
|
||||
const IMAGE_CAPABILITIES: MediaCapabilities = {
|
||||
audio: false,
|
||||
carousel: false,
|
||||
dynamicTexture: false,
|
||||
navigation: false,
|
||||
playback: false,
|
||||
timeline: false
|
||||
};
|
||||
|
||||
export class ImageMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLImageElement> {
|
||||
readonly capabilities = IMAGE_CAPABILITIES;
|
||||
readonly kind = 'image' as const;
|
||||
|
||||
constructor(readonly element: HTMLImageElement) {}
|
||||
|
||||
get textureSource(): HTMLImageElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
return this.element.getAttribute('title') ||
|
||||
this.element.getAttribute('alt') ||
|
||||
getFilenameTitle(this.element.currentSrc || this.element.src) ||
|
||||
'Image Title';
|
||||
}
|
||||
|
||||
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
|
||||
if (this.element.complete && this.element.naturalWidth > 0) {
|
||||
queueMicrotask(onReady);
|
||||
}
|
||||
|
||||
this.element.addEventListener('load', onReady);
|
||||
this.element.addEventListener('error', onError);
|
||||
}
|
||||
|
||||
hideElement(): void {
|
||||
this.element.style.display = 'none';
|
||||
}
|
||||
|
||||
load(): void {
|
||||
// Images begin loading from markup. Kept for parity with video media.
|
||||
}
|
||||
|
||||
next(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
previous(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
shouldUpdateTexture(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
showElement(): void {
|
||||
this.element.style.display = '';
|
||||
}
|
||||
}
|
||||
@@ -1,70 +1,84 @@
|
||||
import { ImageCarouselMediaAdapter } from './image-carousel-media-adapter.js';
|
||||
import { ImageMediaAdapter } from './image-media-adapter.js';
|
||||
import { VideoMediaAdapter } from './video-media-adapter.js';
|
||||
|
||||
export type MediaCapabilities = {
|
||||
audio: boolean;
|
||||
carousel: boolean;
|
||||
dynamicTexture: boolean;
|
||||
navigation: boolean;
|
||||
playback: boolean;
|
||||
timeline: boolean;
|
||||
};
|
||||
|
||||
export type MediaLoadCallbacks = {
|
||||
onError: (event: Event) => void;
|
||||
onReady: () => void;
|
||||
};
|
||||
|
||||
export type MediaKind = 'image' | 'video';
|
||||
|
||||
export interface MediaAdapter<TElement extends HTMLElement = HTMLElement, TTextureSource = TElement> {
|
||||
readonly capabilities: MediaCapabilities;
|
||||
readonly element: TElement;
|
||||
readonly kind: string;
|
||||
readonly kind: MediaKind;
|
||||
readonly textureSource: TTextureSource;
|
||||
bindLoadState(callbacks: MediaLoadCallbacks): void;
|
||||
getTitle(): string;
|
||||
hideElement(): void;
|
||||
load(): void;
|
||||
next?(): boolean;
|
||||
previous?(): boolean;
|
||||
shouldUpdateTexture(): boolean;
|
||||
showElement(): void;
|
||||
}
|
||||
|
||||
export type SupportedMediaAdapter = VideoMediaAdapter;
|
||||
export type SupportedMediaAdapter = ImageCarouselMediaAdapter | ImageMediaAdapter | VideoMediaAdapter;
|
||||
|
||||
const VIDEO_CAPABILITIES: MediaCapabilities = {
|
||||
audio: true,
|
||||
dynamicTexture: true,
|
||||
playback: true,
|
||||
timeline: true
|
||||
export {
|
||||
ImageCarouselMediaAdapter,
|
||||
ImageMediaAdapter,
|
||||
VideoMediaAdapter
|
||||
};
|
||||
|
||||
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) {
|
||||
const mediaElements = Array.from(
|
||||
playerContainer.querySelectorAll<HTMLVideoElement | HTMLImageElement>('video,img')
|
||||
);
|
||||
const videoElements = mediaElements.filter((element) => element.tagName.toLowerCase() === 'video');
|
||||
const imageElements = mediaElements.filter((element) => element.tagName.toLowerCase() === 'img') as HTMLImageElement[];
|
||||
const isCarousel = isCarouselEnabled(playerContainer);
|
||||
|
||||
if (isCarousel) {
|
||||
if (videoElements.length > 0 || imageElements.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ImageCarouselMediaAdapter(imageElements);
|
||||
}
|
||||
|
||||
if (mediaElements.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
videoElement.classList.add('vrwp-video');
|
||||
return new VideoMediaAdapter(videoElement);
|
||||
const mediaElement = mediaElements[0];
|
||||
const tagName = mediaElement.tagName.toLowerCase();
|
||||
mediaElement.classList.add('vrwp-media');
|
||||
|
||||
if (tagName === 'video') {
|
||||
mediaElement.classList.add('vrwp-video');
|
||||
return new VideoMediaAdapter(mediaElement as HTMLVideoElement);
|
||||
}
|
||||
|
||||
if (tagName === 'img') {
|
||||
mediaElement.classList.add('vrwp-image');
|
||||
return new ImageMediaAdapter(mediaElement as HTMLImageElement);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isCarouselEnabled(playerContainer: HTMLElement): boolean {
|
||||
const carouselValue = playerContainer.dataset?.carousel;
|
||||
return carouselValue !== undefined && carouselValue.toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
@@ -6,21 +6,22 @@ type MediaControllerOptions = {
|
||||
};
|
||||
|
||||
type HandleMediaEndedOptions = {
|
||||
cleanupFailedVrExit: () => void;
|
||||
exitVr: () => Promise<void>;
|
||||
isIn2DMode: () => boolean;
|
||||
isInVr: () => boolean;
|
||||
on2DEnded: () => void;
|
||||
onVrEnded: () => void;
|
||||
resetToOriginalState: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_SKIP_SECONDS = 15;
|
||||
const SEAMLESS_LOOP_LOOKAHEAD_SECONDS = 0.18;
|
||||
|
||||
export class MediaController {
|
||||
private readonly is2DModeActive: () => boolean;
|
||||
private readonly on2DPlaybackResume: () => void;
|
||||
private readonly playButton?: HTMLButtonElement;
|
||||
private readonly video: HTMLVideoElement;
|
||||
private loopFrameCallbackId: number | undefined;
|
||||
|
||||
constructor({ is2DModeActive, on2DPlaybackResume, playButton, video }: MediaControllerOptions) {
|
||||
this.is2DModeActive = is2DModeActive;
|
||||
@@ -39,21 +40,21 @@ export class MediaController {
|
||||
this.video.currentTime = Math.min(this.video.duration, this.video.currentTime + seconds);
|
||||
}
|
||||
|
||||
handleTimeUpdate(): void {
|
||||
this.loopBeforeEndedIfNeeded();
|
||||
}
|
||||
|
||||
handleEnded({
|
||||
cleanupFailedVrExit,
|
||||
exitVr,
|
||||
isIn2DMode,
|
||||
isInVr,
|
||||
on2DEnded,
|
||||
onVrEnded,
|
||||
resetToOriginalState
|
||||
}: HandleMediaEndedOptions): void {
|
||||
this.pauseIfPlaying();
|
||||
|
||||
if (isInVr()) {
|
||||
exitVr().catch((err) => {
|
||||
console.error('Error during automatic VR exit on video end:', err);
|
||||
cleanupFailedVrExit();
|
||||
});
|
||||
onVrEnded();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,17 +70,25 @@ export class MediaController {
|
||||
this.playButton?.classList.add('hidden');
|
||||
}
|
||||
|
||||
isLooping(): boolean {
|
||||
return this.video.loop;
|
||||
}
|
||||
|
||||
pauseIfPlaying(): void {
|
||||
if (!this.video.paused) {
|
||||
this.video.pause();
|
||||
}
|
||||
this.syncSeamlessLoopMonitor();
|
||||
}
|
||||
|
||||
play(): Promise<void> {
|
||||
return this.video.play();
|
||||
const playPromise = this.video.play();
|
||||
playPromise.then(() => this.syncSeamlessLoopMonitor()).catch(() => {});
|
||||
return playPromise;
|
||||
}
|
||||
|
||||
resetToOriginalState(): void {
|
||||
this.stopSeamlessLoopMonitor();
|
||||
this.video.pause();
|
||||
this.video.currentTime = 0;
|
||||
this.video.controls = false;
|
||||
@@ -105,11 +114,17 @@ export class MediaController {
|
||||
this.video.muted = !this.video.muted;
|
||||
}
|
||||
|
||||
toggleLoop(): boolean {
|
||||
this.video.loop = !this.video.loop;
|
||||
this.syncSeamlessLoopMonitor();
|
||||
return this.video.loop;
|
||||
}
|
||||
|
||||
togglePlayPause(): void {
|
||||
if (!this.video.currentSrc) return;
|
||||
|
||||
if (this.video.paused || this.video.ended) {
|
||||
if (this.video.ended && this.is2DModeActive()) {
|
||||
if (this.video.ended) {
|
||||
this.video.currentTime = 0;
|
||||
}
|
||||
|
||||
@@ -117,6 +132,7 @@ export class MediaController {
|
||||
const playPromise = this.video.play() as Promise<void> | undefined;
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.then(() => {
|
||||
this.syncSeamlessLoopMonitor();
|
||||
if (this.is2DModeActive() && this.video.ended === false) {
|
||||
this.on2DPlaybackResume();
|
||||
}
|
||||
@@ -129,5 +145,56 @@ export class MediaController {
|
||||
}
|
||||
|
||||
this.video.pause();
|
||||
this.syncSeamlessLoopMonitor();
|
||||
}
|
||||
|
||||
syncSeamlessLoopMonitor(): void {
|
||||
const requestVideoFrameCallback = this.video.requestVideoFrameCallback?.bind(this.video);
|
||||
if (!requestVideoFrameCallback) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.video.loop || this.video.paused) {
|
||||
this.stopSeamlessLoopMonitor();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.loopFrameCallbackId !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loopFrameCallbackId = requestVideoFrameCallback((now, metadata) => {
|
||||
this.loopFrameCallbackId = undefined;
|
||||
this.loopBeforeEndedIfNeeded(metadata?.mediaTime);
|
||||
this.syncSeamlessLoopMonitor();
|
||||
});
|
||||
}
|
||||
|
||||
private stopSeamlessLoopMonitor(): void {
|
||||
if (this.loopFrameCallbackId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.video.cancelVideoFrameCallback?.(this.loopFrameCallbackId);
|
||||
this.loopFrameCallbackId = undefined;
|
||||
}
|
||||
|
||||
private loopBeforeEndedIfNeeded(mediaTime = this.video.currentTime): boolean {
|
||||
if (!this.video.loop || this.video.paused) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isFinite(this.video.duration) || this.video.duration <= SEAMLESS_LOOP_LOOKAHEAD_SECONDS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mediaTime < this.video.duration - SEAMLESS_LOOP_LOOKAHEAD_SECONDS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.video.currentTime = 0;
|
||||
const playPromise = this.video.play() as Promise<void> | undefined;
|
||||
playPromise?.catch((err) => console.error('Error restarting seamless video loop:', err));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
3
src/vr180player/media/media-title.ts
Normal file
3
src/vr180player/media/media-title.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function getFilenameTitle(source: string): string {
|
||||
return source.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || '';
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
type VideoEventCallbacks = {
|
||||
onEnded: () => void;
|
||||
onTimeUpdate?: () => void;
|
||||
onPlaybackStateChange: () => void;
|
||||
onTimelineChange: () => void;
|
||||
onVolumeChange: () => void;
|
||||
@@ -12,6 +13,7 @@ type BindVideoEventsOptions = VideoEventCallbacks & {
|
||||
|
||||
export function bindVideoEvents({
|
||||
onEnded,
|
||||
onTimeUpdate,
|
||||
onPlaybackStateChange,
|
||||
onTimelineChange,
|
||||
onVolumeChange,
|
||||
@@ -35,6 +37,7 @@ export function bindVideoEvents({
|
||||
};
|
||||
|
||||
video.ontimeupdate = () => {
|
||||
onTimeUpdate?.();
|
||||
if (isFinite(video.duration)) {
|
||||
onTimelineChange();
|
||||
}
|
||||
|
||||
65
src/vr180player/media/video-media-adapter.ts
Normal file
65
src/vr180player/media/video-media-adapter.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
MediaAdapter,
|
||||
MediaCapabilities,
|
||||
MediaLoadCallbacks
|
||||
} from './media-adapter.js';
|
||||
|
||||
const VIDEO_CAPABILITIES: MediaCapabilities = {
|
||||
audio: true,
|
||||
carousel: false,
|
||||
dynamicTexture: true,
|
||||
navigation: true,
|
||||
playback: true,
|
||||
timeline: true
|
||||
};
|
||||
|
||||
export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVideoElement> {
|
||||
readonly capabilities = VIDEO_CAPABILITIES;
|
||||
readonly kind = 'video' as const;
|
||||
|
||||
constructor(readonly element: HTMLVideoElement) {}
|
||||
|
||||
get textureSource(): HTMLVideoElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
return this.element.getAttribute('title') ||
|
||||
this.element.querySelector('source')?.src.split('/').pop()?.split('.')[0].replace(/-/g, ' ') ||
|
||||
'Video Title';
|
||||
}
|
||||
|
||||
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
|
||||
if (this.element.readyState >= this.element.HAVE_METADATA) {
|
||||
queueMicrotask(onReady);
|
||||
}
|
||||
|
||||
this.element.addEventListener('loadedmetadata', onReady);
|
||||
this.element.addEventListener('canplaythrough', onReady);
|
||||
this.element.addEventListener('error', onError);
|
||||
}
|
||||
|
||||
hideElement(): void {
|
||||
this.element.style.display = 'none';
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.element.load();
|
||||
}
|
||||
|
||||
next(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
previous(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
shouldUpdateTexture(): boolean {
|
||||
return !this.element.paused && !this.element.ended;
|
||||
}
|
||||
|
||||
showElement(): void {
|
||||
this.element.style.display = '';
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,17 @@ import {
|
||||
showFallbackCanvas
|
||||
} from '../rendering/renderer-lifecycle.js';
|
||||
import { TwoDControlPanel } from '../dom/two-d-control-panel.js';
|
||||
import type { MediaCapabilities } from '../media/media-adapter.js';
|
||||
|
||||
type TwoDModeCallbacks = {
|
||||
createMediaTexture: () => any;
|
||||
forward: () => void;
|
||||
getIsLooping: () => boolean;
|
||||
positionPlaneForPresentation: (isFallback2D?: boolean) => void;
|
||||
rewind: () => void;
|
||||
seekToProgress: (progress: number) => void;
|
||||
showActiveContentMesh: () => void;
|
||||
toggleLoop: () => boolean;
|
||||
toggleMute: () => void;
|
||||
togglePlayPause: () => void;
|
||||
};
|
||||
@@ -21,6 +24,8 @@ type TwoDModeCallbacks = {
|
||||
type TwoDModeOptions = {
|
||||
callbacks: TwoDModeCallbacks;
|
||||
fullscreenTarget: HTMLElement;
|
||||
getControlsAutoHideDelayMs?: () => number;
|
||||
mediaCapabilities: MediaCapabilities;
|
||||
getActiveContentMesh: () => any;
|
||||
getCamera: () => any;
|
||||
getCameraControls: () => FallbackCameraControls | undefined;
|
||||
@@ -40,6 +45,7 @@ export class TwoDMode {
|
||||
private readonly callbacks: TwoDModeCallbacks;
|
||||
private readonly controls: TwoDControlPanel;
|
||||
private readonly fullscreenTarget: HTMLElement;
|
||||
private readonly mediaCapabilities: MediaCapabilities;
|
||||
private readonly getActiveContentMesh: () => any;
|
||||
private readonly getCamera: () => any;
|
||||
private readonly getCameraControls: () => FallbackCameraControls | undefined;
|
||||
@@ -55,6 +61,7 @@ export class TwoDMode {
|
||||
constructor(options: TwoDModeOptions) {
|
||||
this.callbacks = options.callbacks;
|
||||
this.fullscreenTarget = options.fullscreenTarget;
|
||||
this.mediaCapabilities = options.mediaCapabilities;
|
||||
this.getActiveContentMesh = options.getActiveContentMesh;
|
||||
this.getCamera = options.getCamera;
|
||||
this.getCameraControls = options.getCameraControls;
|
||||
@@ -68,6 +75,7 @@ export class TwoDMode {
|
||||
|
||||
this.controls = new TwoDControlPanel({
|
||||
callbacks: {
|
||||
getIsLooping: this.callbacks.getIsLooping,
|
||||
onForward: () => {
|
||||
this.callbacks.forward();
|
||||
},
|
||||
@@ -80,9 +88,12 @@ export class TwoDMode {
|
||||
},
|
||||
onSeek: (progress) => {
|
||||
this.callbacks.seekToProgress(progress);
|
||||
}
|
||||
},
|
||||
onToggleLoop: this.callbacks.toggleLoop
|
||||
},
|
||||
mediaCapabilities: this.mediaCapabilities,
|
||||
fullscreenTarget: this.fullscreenTarget,
|
||||
getAutoHideDelayMs: options.getControlsAutoHideDelayMs,
|
||||
getIsActive: () => this.active,
|
||||
playerContainer: this.playerContainer,
|
||||
title: options.title
|
||||
@@ -120,7 +131,9 @@ export class TwoDMode {
|
||||
this.callbacks.showActiveContentMesh();
|
||||
}
|
||||
|
||||
this.callbacks.togglePlayPause();
|
||||
if (this.mediaCapabilities.playback) {
|
||||
this.callbacks.togglePlayPause();
|
||||
}
|
||||
this.addEventListeners(canvas);
|
||||
this.controls.show();
|
||||
this.positionControls();
|
||||
@@ -172,6 +185,7 @@ export class TwoDMode {
|
||||
|
||||
updateTimeline(): void {
|
||||
if (!this.active) return;
|
||||
if (!this.mediaCapabilities.timeline) return;
|
||||
|
||||
const video = this.getVideo();
|
||||
if (video) {
|
||||
@@ -181,6 +195,7 @@ export class TwoDMode {
|
||||
|
||||
updatePlaybackButton(): void {
|
||||
if (!this.active) return;
|
||||
if (!this.mediaCapabilities.playback) return;
|
||||
|
||||
const video = this.getVideo();
|
||||
if (video) {
|
||||
@@ -190,6 +205,7 @@ export class TwoDMode {
|
||||
|
||||
updateMuteButton(): void {
|
||||
if (!this.active) return;
|
||||
if (!this.mediaCapabilities.audio) return;
|
||||
|
||||
const video = this.getVideo();
|
||||
if (video) {
|
||||
@@ -199,6 +215,7 @@ export class TwoDMode {
|
||||
|
||||
handleVideoEnd(): void {
|
||||
if (!this.active) return;
|
||||
if (!this.mediaCapabilities.playback) return;
|
||||
|
||||
this.controls.showPersistent();
|
||||
this.updatePlaybackButton();
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
PLANE_DISTANCE,
|
||||
type HeadLockMode,
|
||||
type ProjectionMode
|
||||
} from '../config.js';
|
||||
|
||||
export function isLeftEyeCamera(renderingRenderer: any, activeCamera: any): boolean {
|
||||
const xrCamera = renderingRenderer.xr.getCamera();
|
||||
|
||||
@@ -78,3 +84,70 @@ export function positionPlaneForPresentation(
|
||||
const zPosition = isFallback2D && camera2D ? camera2D.position.z - plane2DDistance : -planeDistance;
|
||||
planeMesh.position.set(0, 1.6, zPosition);
|
||||
}
|
||||
|
||||
export function shouldLockContentToHeadPosition(headLockMode: HeadLockMode, projectionMode: ProjectionMode): boolean {
|
||||
if (headLockMode === 'position') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (headLockMode === 'none') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return projectionMode === 'vr180';
|
||||
}
|
||||
|
||||
export function applyHeadPositionLock(
|
||||
contentMesh: any,
|
||||
activeCamera: any,
|
||||
projectionMode: ProjectionMode,
|
||||
isHeadPositionLocked: boolean,
|
||||
planeDistance = PLANE_DISTANCE
|
||||
): void {
|
||||
if (!contentMesh || !activeCamera || !isHeadPositionLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraPosition = getCameraWorldPosition(activeCamera);
|
||||
if (!cameraPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectionMode === 'plane') {
|
||||
contentMesh.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z - planeDistance);
|
||||
return;
|
||||
}
|
||||
|
||||
contentMesh.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z);
|
||||
}
|
||||
|
||||
export function resetHeadPositionLockedContent(
|
||||
vr180Mesh: any,
|
||||
planeMesh: any,
|
||||
planeDistance = PLANE_DISTANCE
|
||||
): void {
|
||||
vr180Mesh?.position?.set?.(0, 0, 0);
|
||||
planeMesh?.position?.set?.(0, 1.6, -planeDistance);
|
||||
}
|
||||
|
||||
function getCameraWorldPosition(activeCamera: any): { x: number; y: number; z: number } | null {
|
||||
const matrixElements = activeCamera?.matrixWorld?.elements;
|
||||
if (matrixElements && matrixElements.length >= 16) {
|
||||
return {
|
||||
x: matrixElements[12],
|
||||
y: matrixElements[13],
|
||||
z: matrixElements[14]
|
||||
};
|
||||
}
|
||||
|
||||
const position = activeCamera?.position;
|
||||
if (position) {
|
||||
return {
|
||||
x: position.x || 0,
|
||||
y: position.y || 0,
|
||||
z: position.z || 0
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export class MediaTextureManager<TSource, TTexture extends ManagedTexture = Mana
|
||||
private texture: TTexture | null = null;
|
||||
private readonly createTexture: TextureFactory<TSource, TTexture>;
|
||||
private readonly shouldUpdateTexture: () => boolean;
|
||||
private readonly source: TSource;
|
||||
private source: TSource;
|
||||
|
||||
constructor(source: TSource, createTexture: TextureFactory<TSource, TTexture>, shouldUpdateTexture: () => boolean) {
|
||||
this.createTexture = createTexture;
|
||||
@@ -26,6 +26,10 @@ export class MediaTextureManager<TSource, TTexture extends ManagedTexture = Mana
|
||||
return this.texture;
|
||||
}
|
||||
|
||||
setSource(source: TSource): void {
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
assignToMaterial(material: ManagedMaterial<TTexture>): TTexture {
|
||||
const texture = this.create();
|
||||
material.map = texture;
|
||||
|
||||
@@ -98,3 +98,20 @@ export function createVideoTexture(video: HTMLVideoElement) {
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
return texture;
|
||||
}
|
||||
|
||||
export function createImageTexture(image: HTMLImageElement) {
|
||||
const texture = new THREE.Texture(image);
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
export function createMediaTexture(source: HTMLImageElement | HTMLVideoElement) {
|
||||
if (source.tagName.toLowerCase() === 'img') {
|
||||
return createImageTexture(source as HTMLImageElement);
|
||||
}
|
||||
|
||||
return createVideoTexture(source as HTMLVideoElement);
|
||||
}
|
||||
|
||||
272
src/vr180player/styles/vr180-player.css
Normal file
272
src/vr180player/styles/vr180-player.css
Normal file
@@ -0,0 +1,272 @@
|
||||
.vrwp {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vrwp [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vrwp-media,
|
||||
.vrwp canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.vrwp-play-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
transition: opacity 0.3s ease, transform 0.2s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.vrwp-play-button:hover {
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
|
||||
.vrwp-play-button:active {
|
||||
transform: translate(-50%, -50%) scale(0.95);
|
||||
}
|
||||
|
||||
.vrwp-play-button.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vrwp-play-button .vrwp-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
|
||||
.vrwp-launcher-host {
|
||||
position: fixed;
|
||||
left: -1px;
|
||||
top: -1px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vrwp-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483647;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
background: rgba(8, 8, 8, 0.82);
|
||||
}
|
||||
|
||||
.vrwp-modal[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vrwp-modal-dialog {
|
||||
position: relative;
|
||||
width: min(1120px, 100%);
|
||||
max-height: calc(100vh - 36px);
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: #111;
|
||||
box-shadow: 0 18px 70px rgba(0, 0, 0, 0.42);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.vrwp-modal-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vrwp-modal .vrwp {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vrwp-modal-close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vrwp-modal-close:hover {
|
||||
background: rgba(0, 0, 0, 0.76);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.vrwp-play-button {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.vrwp-play-button {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.vrwp-panel {
|
||||
position: absolute;
|
||||
bottom: 10%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80%;
|
||||
padding: 20px;
|
||||
border-radius: 30px;
|
||||
background: rgba(0, 0, 0, 0.70);
|
||||
color: #fff;
|
||||
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vrwp-panel.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.vrwp-status {
|
||||
margin: 0 12px 12px 12px;
|
||||
}
|
||||
|
||||
.vrwp-video-title {
|
||||
text-align: center;
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vrwp-current-time,
|
||||
.vrwp-total-time {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.vrwp-progress {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr min-content;
|
||||
grid-gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vrwp-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #666;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vrwp-played {
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
height: 4px;
|
||||
width: 0%;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
.vrwp-controls {
|
||||
display: grid;
|
||||
grid-template-areas: "full lflex nav rflex loop mute";
|
||||
grid-template-columns: 44px 1fr 156px 1fr 44px 44px;
|
||||
column-gap: 8px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.vrwp-panel button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
transition: color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.vrwp-panel button:hover {
|
||||
color: #d8d8d8;
|
||||
}
|
||||
|
||||
.vrwp-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.vrwp-fullscreen,
|
||||
.vrwp-loop,
|
||||
.vrwp-mute,
|
||||
.vrwp-back,
|
||||
.vrwp-play-toggle,
|
||||
.vrwp-forward {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.vrwp-fullscreen {
|
||||
grid-area: full;
|
||||
}
|
||||
|
||||
.vrwp-mute {
|
||||
grid-area: mute;
|
||||
}
|
||||
|
||||
.vrwp-loop {
|
||||
grid-area: loop;
|
||||
}
|
||||
|
||||
.vrwp-loop.active {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.vrwp-nav {
|
||||
grid-area: nav;
|
||||
display: grid;
|
||||
grid-template-columns: 44px 44px 44px;
|
||||
grid-gap: 12px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.vrwp-skip-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -48%);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
1
src/vr180player/types.d.ts
vendored
1
src/vr180player/types.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
declare module 'https://unpkg.com/three/build/three.module.js' {
|
||||
export const Matrix4: any;
|
||||
export const CanvasTexture: any;
|
||||
export const Texture: any;
|
||||
export const VideoTexture: any;
|
||||
export const LinearFilter: any;
|
||||
export const SRGBColorSpace: any;
|
||||
|
||||
25
src/vr180player/utils/control-panel-timing.ts
Normal file
25
src/vr180player/utils/control-panel-timing.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export const DEFAULT_MENU_AUTO_HIDE_DELAY_MS = 10000;
|
||||
export const PLAYING_VIDEO_MENU_AUTO_HIDE_DELAY_MS = 2500;
|
||||
export const DEFAULT_2D_CONTROL_AUTO_HIDE_DELAY_MS = 3000;
|
||||
export const PLAYING_VIDEO_2D_CONTROL_AUTO_HIDE_DELAY_MS = 1500;
|
||||
|
||||
type VideoPlaybackState = Pick<HTMLVideoElement, 'ended' | 'paused'>;
|
||||
|
||||
type AutoHideDelayOptions = {
|
||||
idleDelayMs?: number;
|
||||
playingDelayMs?: number;
|
||||
};
|
||||
|
||||
export function isVideoActivelyPlaying(video: VideoPlaybackState | null | undefined): boolean {
|
||||
return Boolean(video && !video.paused && !video.ended);
|
||||
}
|
||||
|
||||
export function getVideoAwareAutoHideDelayMs(
|
||||
video: VideoPlaybackState | null | undefined,
|
||||
{
|
||||
idleDelayMs = DEFAULT_MENU_AUTO_HIDE_DELAY_MS,
|
||||
playingDelayMs = PLAYING_VIDEO_MENU_AUTO_HIDE_DELAY_MS
|
||||
}: AutoHideDelayOptions = {}
|
||||
): number {
|
||||
return isVideoActivelyPlaying(video) ? playingDelayMs : idleDelayMs;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
39
src/vr180player/xr/input-mode.ts
Normal file
39
src/vr180player/xr/input-mode.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type PointerInputMode = 'controller';
|
||||
|
||||
export type PointerInputModeCarrier = {
|
||||
controller?: {
|
||||
userData?: any;
|
||||
};
|
||||
pointerInputMode?: PointerInputMode;
|
||||
};
|
||||
|
||||
export function rememberPointerInputMode(
|
||||
inputSource: PointerInputModeCarrier,
|
||||
event: any,
|
||||
fallbackMode: PointerInputMode
|
||||
): void {
|
||||
const eventInputSource = event?.data?.inputSource || event?.inputSource || event?.data;
|
||||
const nextMode = getPointerInputMode(eventInputSource) || fallbackMode;
|
||||
inputSource.pointerInputMode = nextMode;
|
||||
|
||||
if (!inputSource.controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
inputSource.controller.userData = {
|
||||
...inputSource.controller.userData,
|
||||
vrwpInputSource: inputSource
|
||||
};
|
||||
}
|
||||
|
||||
export function getPointerInputMode(eventInputSource: any): PointerInputMode | null {
|
||||
if (!eventInputSource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (eventInputSource.gamepad || eventInputSource.targetRayMode === 'tracked-pointer') {
|
||||
return 'controller';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
197
src/vr180player/xr/input-rig.ts
Normal file
197
src/vr180player/xr/input-rig.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||
import {
|
||||
getSeekProgressFromIntersection,
|
||||
type VrControlPanel
|
||||
} from './vr-control-panel.js';
|
||||
import { rememberPointerInputMode } from './input-mode.js';
|
||||
import {
|
||||
bindOverlayActivity,
|
||||
createControllerOverlay,
|
||||
createPointerOverlay,
|
||||
getPointerIntersectionLength,
|
||||
POINTER_LENGTH,
|
||||
resetInputPointerLengths,
|
||||
setPointerOverlayLength,
|
||||
VrOverlayVisibility
|
||||
} from './pointer-overlays.js';
|
||||
|
||||
export type VrInputRig = {
|
||||
beginSeekDrag: (controller: any, panel: VrControlPanel | undefined, onSeek: (progress: number) => void) => void;
|
||||
hideOverlays: () => void;
|
||||
raycaster: any;
|
||||
showOverlays: (timestamp?: number) => void;
|
||||
update: (timestamp: number, hoverTargets?: any[]) => boolean;
|
||||
};
|
||||
|
||||
type ActiveSeekDrag = {
|
||||
inputSource: VrInputSource;
|
||||
onSeek: (progress: number) => void;
|
||||
panel: VrControlPanel;
|
||||
};
|
||||
|
||||
type AimRay = {
|
||||
direction: any;
|
||||
origin: any;
|
||||
};
|
||||
|
||||
type VrInputSource = {
|
||||
controller: any;
|
||||
controllerPointerOverlay: any;
|
||||
pointerInputMode: 'controller';
|
||||
};
|
||||
|
||||
const tempMatrix = new THREE.Matrix4();
|
||||
|
||||
export function createVrInputRig(scene: any, renderer: any, onSelectStart: (event: any) => void): VrInputRig {
|
||||
const overlayVisibility = new VrOverlayVisibility();
|
||||
const inputSources: VrInputSource[] = [];
|
||||
const raycaster = createPointerRaycaster();
|
||||
const hoverRaycaster = createPointerRaycaster();
|
||||
const dragRaycaster = createPointerRaycaster();
|
||||
let activeSeekDrag: ActiveSeekDrag | null = null;
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const controller = renderer.xr.getController(index);
|
||||
const controllerPointerOverlay = createPointerOverlay(index, overlayVisibility);
|
||||
const inputSource: VrInputSource = {
|
||||
controller,
|
||||
controllerPointerOverlay,
|
||||
pointerInputMode: 'controller'
|
||||
};
|
||||
controller.userData = {
|
||||
...controller.userData,
|
||||
vrwpInputSource: inputSource
|
||||
};
|
||||
inputSources.push(inputSource);
|
||||
|
||||
controller.addEventListener('connected', (event: any) => {
|
||||
rememberPointerInputMode(inputSource, event, 'controller');
|
||||
});
|
||||
controller.addEventListener('selectstart', (event: any) => {
|
||||
rememberPointerInputMode(inputSource, event, 'controller');
|
||||
overlayVisibility.show(getEventTimestamp(event));
|
||||
onSelectStart(event);
|
||||
});
|
||||
controller.addEventListener('selectend', () => {
|
||||
if (activeSeekDrag?.inputSource.controller === controller) {
|
||||
activeSeekDrag = null;
|
||||
}
|
||||
});
|
||||
bindOverlayActivity(controller, overlayVisibility);
|
||||
controller.add(controllerPointerOverlay);
|
||||
scene.add(controller);
|
||||
|
||||
const grip = renderer.xr.getControllerGrip?.(index);
|
||||
if (grip) {
|
||||
grip.addEventListener('connected', (event: any) => {
|
||||
rememberPointerInputMode(inputSource, event, 'controller');
|
||||
});
|
||||
bindOverlayActivity(grip, overlayVisibility);
|
||||
grip.add(createControllerOverlay(index, overlayVisibility));
|
||||
scene.add(grip);
|
||||
}
|
||||
}
|
||||
|
||||
overlayVisibility.hideImmediately();
|
||||
|
||||
return {
|
||||
beginSeekDrag: (controller: any, panel: VrControlPanel | undefined, onSeek: (progress: number) => void) => {
|
||||
const inputSource = getInputSourceByController(inputSources, controller);
|
||||
if (!inputSource || !panel?.seekBarHitAreaMesh) {
|
||||
activeSeekDrag = null;
|
||||
return;
|
||||
}
|
||||
|
||||
activeSeekDrag = { inputSource, onSeek, panel };
|
||||
},
|
||||
hideOverlays: () => overlayVisibility.hideImmediately(),
|
||||
raycaster,
|
||||
showOverlays: (timestamp?: number) => overlayVisibility.show(timestamp),
|
||||
update: (timestamp: number, hoverTargets: any[] = []) => {
|
||||
updateActiveSeekDrag(activeSeekDrag, dragRaycaster);
|
||||
const isHovering = updateInputPointerIntersections(inputSources, hoverTargets, hoverRaycaster);
|
||||
if (isHovering) {
|
||||
overlayVisibility.show(timestamp);
|
||||
}
|
||||
overlayVisibility.update(timestamp);
|
||||
return isHovering;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createPointerRaycaster(): any {
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.near = 0.1;
|
||||
raycaster.far = POINTER_LENGTH;
|
||||
return raycaster;
|
||||
}
|
||||
|
||||
function getInputSourceByController(inputSources: VrInputSource[], controller: any): VrInputSource | undefined {
|
||||
return inputSources.find((inputSource) => inputSource.controller === controller);
|
||||
}
|
||||
|
||||
function updateInputPointerIntersections(
|
||||
inputSources: VrInputSource[],
|
||||
hoverTargets: any[],
|
||||
hoverRaycaster: any
|
||||
): boolean {
|
||||
let isHoveringAnyTarget = false;
|
||||
|
||||
inputSources.forEach((inputSource) => {
|
||||
resetInputPointerLengths(inputSource);
|
||||
const aimRay = getControllerAimRay(inputSource.controller);
|
||||
const pointerOverlay = inputSource.controllerPointerOverlay;
|
||||
if (!aimRay || !pointerOverlay || hoverTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
hoverRaycaster.ray.origin.copy(aimRay.origin);
|
||||
hoverRaycaster.ray.direction.copy(aimRay.direction);
|
||||
const intersections = hoverRaycaster.intersectObjects(hoverTargets, true);
|
||||
if (intersections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPointerOverlayLength(pointerOverlay, getPointerIntersectionLength(intersections[0].distance));
|
||||
isHoveringAnyTarget = true;
|
||||
});
|
||||
|
||||
return isHoveringAnyTarget;
|
||||
}
|
||||
|
||||
function updateActiveSeekDrag(activeSeekDrag: ActiveSeekDrag | null, dragRaycaster: any): void {
|
||||
if (!activeSeekDrag?.panel.seekBarHitAreaMesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
const aimRay = getControllerAimRay(activeSeekDrag.inputSource.controller);
|
||||
if (!aimRay) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragRaycaster.ray.origin.copy(aimRay.origin);
|
||||
dragRaycaster.ray.direction.copy(aimRay.direction);
|
||||
|
||||
const intersections = dragRaycaster.intersectObject(activeSeekDrag.panel.seekBarHitAreaMesh, true);
|
||||
if (intersections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeSeekDrag.onSeek(getSeekProgressFromIntersection(activeSeekDrag.panel, intersections[0].point));
|
||||
}
|
||||
|
||||
function getControllerAimRay(controller: any): AimRay | null {
|
||||
if (!controller) {
|
||||
return null;
|
||||
}
|
||||
|
||||
controller.updateMatrixWorld();
|
||||
tempMatrix.identity().extractRotation(controller.matrixWorld);
|
||||
const origin = new THREE.Vector3().setFromMatrixPosition(controller.matrixWorld);
|
||||
const direction = new THREE.Vector3(0, 0, -1).applyMatrix4(tempMatrix);
|
||||
return { direction, origin };
|
||||
}
|
||||
|
||||
function getEventTimestamp(event: any): number {
|
||||
return Number.isFinite(event?.timeStamp) ? event.timeStamp : performance.now();
|
||||
}
|
||||
252
src/vr180player/xr/pointer-overlays.ts
Normal file
252
src/vr180player/xr/pointer-overlays.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||
|
||||
export type PointerOverlayInputSource = {
|
||||
controllerPointerOverlay: any;
|
||||
};
|
||||
|
||||
export type VrOverlayVisibilityOptions = {
|
||||
fadeDurationMs?: number;
|
||||
hideDelayMs?: number;
|
||||
};
|
||||
|
||||
export const INPUT_OVERLAY_HIDE_DELAY_MS = 1600;
|
||||
export const INPUT_OVERLAY_FADE_DURATION_MS = 260;
|
||||
export const INPUT_OVERLAY_RENDER_ORDER = 10000;
|
||||
export const POINTER_LENGTH = 5;
|
||||
export const POINTER_MIN_LENGTH = 0.06;
|
||||
export const POINTER_HIT_SURFACE_OFFSET = 0.015;
|
||||
|
||||
export class VrOverlayVisibility {
|
||||
private readonly fadeDurationMs: number;
|
||||
private readonly hideDelayMs: number;
|
||||
private readonly objects: any[] = [];
|
||||
private opacity = 0;
|
||||
private targetOpacity = 0;
|
||||
private visibleUntil = 0;
|
||||
|
||||
constructor({
|
||||
fadeDurationMs = INPUT_OVERLAY_FADE_DURATION_MS,
|
||||
hideDelayMs = INPUT_OVERLAY_HIDE_DELAY_MS
|
||||
}: VrOverlayVisibilityOptions = {}) {
|
||||
this.fadeDurationMs = fadeDurationMs;
|
||||
this.hideDelayMs = hideDelayMs;
|
||||
}
|
||||
|
||||
register(object: any): void {
|
||||
this.objects.push(object);
|
||||
this.setObjectVisible(object, this.targetOpacity > 0 || this.opacity > 0.001);
|
||||
this.setObjectOpacity(object, this.opacity);
|
||||
}
|
||||
|
||||
show(timestamp = performance.now()): void {
|
||||
this.visibleUntil = timestamp + this.hideDelayMs;
|
||||
this.targetOpacity = 1;
|
||||
this.objects.forEach((object) => this.setObjectVisible(object, true));
|
||||
}
|
||||
|
||||
hideImmediately(): void {
|
||||
this.visibleUntil = 0;
|
||||
this.opacity = 0;
|
||||
this.targetOpacity = 0;
|
||||
this.objects.forEach((object) => {
|
||||
this.setObjectOpacity(object, 0);
|
||||
this.setObjectVisible(object, false);
|
||||
});
|
||||
}
|
||||
|
||||
update(timestamp: number): void {
|
||||
if (this.targetOpacity > 0 && timestamp >= this.visibleUntil) {
|
||||
this.targetOpacity = 0;
|
||||
}
|
||||
|
||||
const shouldBeVisible = this.targetOpacity > 0 || this.opacity > 0.001;
|
||||
this.objects.forEach((object) => this.setObjectVisible(object, shouldBeVisible));
|
||||
|
||||
if (this.opacity === this.targetOpacity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fadeStep = this.fadeDurationMs <= 0
|
||||
? 1
|
||||
: Math.min(1, 16.67 / this.fadeDurationMs);
|
||||
const direction = this.opacity < this.targetOpacity ? 1 : -1;
|
||||
this.opacity = Math.max(0, Math.min(1, this.opacity + fadeStep * direction));
|
||||
|
||||
if ((direction > 0 && this.opacity >= this.targetOpacity) || (direction < 0 && this.opacity <= this.targetOpacity)) {
|
||||
this.opacity = this.targetOpacity;
|
||||
}
|
||||
|
||||
this.objects.forEach((object) => {
|
||||
this.setObjectOpacity(object, this.opacity);
|
||||
this.setObjectVisible(object, this.opacity > 0.001);
|
||||
});
|
||||
}
|
||||
|
||||
private setObjectVisible(object: any, isVisible: boolean): void {
|
||||
const objectVisible = isVisible && object.userData?.vrwpOverlayAvailable !== false;
|
||||
object.visible = objectVisible;
|
||||
object.traverse?.((child: any) => {
|
||||
child.visible = objectVisible;
|
||||
});
|
||||
}
|
||||
|
||||
private setObjectOpacity(object: any, opacity: number): void {
|
||||
object.traverse?.((child: any) => {
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||
materials.filter(Boolean).forEach((material: any) => {
|
||||
material.opacity = opacity * getOverlayMaterialMaxOpacity(material);
|
||||
material.transparent = true;
|
||||
material.depthTest = false;
|
||||
material.depthWrite = false;
|
||||
material.needsUpdate = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function bindOverlayActivity(target: any, overlayVisibility: VrOverlayVisibility): void {
|
||||
[
|
||||
'connected',
|
||||
'disconnected',
|
||||
'select',
|
||||
'selectend',
|
||||
'squeezestart',
|
||||
'squeeze',
|
||||
'squeezeend'
|
||||
].forEach((eventName) => {
|
||||
target.addEventListener?.(eventName, () => overlayVisibility.show());
|
||||
});
|
||||
}
|
||||
|
||||
export function createPointerOverlay(index: number, overlayVisibility: VrOverlayVisibility): any {
|
||||
const group = new THREE.Group();
|
||||
group.name = `vrPointerOverlay${index}`;
|
||||
|
||||
const pointerMaterial = createOverlayLineMaterial(index === 0 ? 0xb9e8ff : 0xffd99a, 0.28);
|
||||
const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(0, 0, -POINTER_LENGTH)
|
||||
]);
|
||||
const pointerLine = new THREE.Line(pointerGeometry, pointerMaterial);
|
||||
pointerLine.name = `vrPointerRay${index}`;
|
||||
pointerLine.renderOrder = INPUT_OVERLAY_RENDER_ORDER;
|
||||
group.add(pointerLine);
|
||||
|
||||
const tipMaterial = createOverlayMeshMaterial(index === 0 ? 0xd8f5ff : 0xffe6ba, 0.42);
|
||||
const tipMesh = new THREE.Mesh(new THREE.SphereGeometry(0.01, 10, 6), tipMaterial);
|
||||
tipMesh.name = `vrPointerTip${index}`;
|
||||
tipMesh.position.z = -POINTER_LENGTH;
|
||||
tipMesh.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 1;
|
||||
group.add(tipMesh);
|
||||
|
||||
group.userData = {
|
||||
...group.userData,
|
||||
vrwpPointerLength: POINTER_LENGTH,
|
||||
vrwpPointerLine: pointerLine,
|
||||
vrwpPointerTip: tipMesh
|
||||
};
|
||||
|
||||
overlayVisibility.register(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function createControllerOverlay(index: number, overlayVisibility: VrOverlayVisibility): any {
|
||||
const group = new THREE.Group();
|
||||
group.name = `vrControllerOverlay${index}`;
|
||||
|
||||
const material = createOverlayLineMaterial(index === 0 ? 0xb9e8ff : 0xffd99a, 0.24);
|
||||
const outlineGeometry = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-0.045, -0.025, -0.08),
|
||||
new THREE.Vector3(0.045, -0.025, -0.08),
|
||||
new THREE.Vector3(0.055, 0.025, -0.02),
|
||||
new THREE.Vector3(0.025, 0.035, 0.05),
|
||||
new THREE.Vector3(-0.025, 0.035, 0.05),
|
||||
new THREE.Vector3(-0.055, 0.025, -0.02),
|
||||
new THREE.Vector3(-0.045, -0.025, -0.08)
|
||||
]);
|
||||
const outline = new THREE.Line(outlineGeometry, material);
|
||||
outline.name = `vrControllerOutline${index}`;
|
||||
outline.renderOrder = INPUT_OVERLAY_RENDER_ORDER;
|
||||
group.add(outline);
|
||||
|
||||
const originMaterial = createOverlayMeshMaterial(0xffffff, 0.28);
|
||||
const origin = new THREE.Mesh(new THREE.SphereGeometry(0.007, 8, 5), originMaterial);
|
||||
origin.name = `vrControllerOrigin${index}`;
|
||||
origin.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 1;
|
||||
group.add(origin);
|
||||
|
||||
overlayVisibility.register(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function resetInputPointerLengths(inputSource: PointerOverlayInputSource): void {
|
||||
setPointerOverlayLength(inputSource.controllerPointerOverlay, POINTER_LENGTH);
|
||||
}
|
||||
|
||||
export function getPointerIntersectionLength(distance: number): number {
|
||||
return Math.max(
|
||||
POINTER_MIN_LENGTH,
|
||||
Math.min(POINTER_LENGTH, distance - POINTER_HIT_SURFACE_OFFSET)
|
||||
);
|
||||
}
|
||||
|
||||
export function setPointerOverlayLength(pointerOverlay: any, length: number): void {
|
||||
if (!pointerOverlay || pointerOverlay.userData?.vrwpPointerLength === length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerLine = pointerOverlay.userData?.vrwpPointerLine;
|
||||
const pointerTip = pointerOverlay.userData?.vrwpPointerTip;
|
||||
const positionAttribute = pointerLine?.geometry?.getAttribute?.('position') ||
|
||||
pointerLine?.geometry?.attributes?.position;
|
||||
|
||||
if (positionAttribute?.setXYZ) {
|
||||
positionAttribute.setXYZ(1, 0, 0, -length);
|
||||
positionAttribute.needsUpdate = true;
|
||||
pointerLine.geometry.computeBoundingSphere?.();
|
||||
}
|
||||
|
||||
if (pointerTip) {
|
||||
pointerTip.position.z = -length;
|
||||
}
|
||||
|
||||
pointerOverlay.userData = {
|
||||
...pointerOverlay.userData,
|
||||
vrwpPointerLength: length
|
||||
};
|
||||
}
|
||||
|
||||
export function createOverlayLineMaterial(color: number, opacity: number): any {
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
opacity,
|
||||
transparent: true
|
||||
});
|
||||
material.userData = {
|
||||
...material.userData,
|
||||
vrwpOverlayMaxOpacity: opacity
|
||||
};
|
||||
return material;
|
||||
}
|
||||
|
||||
export function createOverlayMeshMaterial(color: number, opacity: number): any {
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
opacity,
|
||||
transparent: true
|
||||
});
|
||||
material.userData = {
|
||||
...material.userData,
|
||||
vrwpOverlayMaxOpacity: opacity
|
||||
};
|
||||
return material;
|
||||
}
|
||||
|
||||
function getOverlayMaterialMaxOpacity(material: any): number {
|
||||
const maxOpacity = material.userData?.vrwpOverlayMaxOpacity;
|
||||
return Number.isFinite(maxOpacity) ? maxOpacity : 1;
|
||||
}
|
||||
@@ -1,182 +1,144 @@
|
||||
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;
|
||||
};
|
||||
import type { MediaCapabilities } from '../media/media-adapter.js';
|
||||
import {
|
||||
createStaticVrButtonTexture,
|
||||
createVrButtonTexture,
|
||||
updateLoopButtonTexture,
|
||||
updatePlayPauseButtonTexture,
|
||||
updateVolumeButtonTexture,
|
||||
type VrButtonTexture
|
||||
} from './vr-panel-button-textures.js';
|
||||
import {
|
||||
VR_PANEL_BUTTON_LAYOUTS,
|
||||
WORLD_SEEK_BAR_WIDTH
|
||||
} from './vr-panel-layout.js';
|
||||
import {
|
||||
createButtonMesh,
|
||||
createPanelBackground,
|
||||
createSeekBarMeshes,
|
||||
createVrPanelGroup
|
||||
} from './vr-panel-meshes.js';
|
||||
|
||||
export type VrControlPanel = {
|
||||
exitButtonMesh: any;
|
||||
forwardButtonMesh: any;
|
||||
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;
|
||||
loopButtonCanvas?: HTMLCanvasElement;
|
||||
loopButtonContext?: CanvasRenderingContext2D | null;
|
||||
loopButtonMesh?: any;
|
||||
loopButtonTexture?: any;
|
||||
playPauseButtonCanvas?: HTMLCanvasElement;
|
||||
playPauseButtonContext?: CanvasRenderingContext2D | null;
|
||||
playPauseButtonMesh?: any;
|
||||
playPauseButtonTexture?: any;
|
||||
rewindButtonMesh?: any;
|
||||
seekBarHitAreaMesh?: any;
|
||||
seekBarProgressMesh?: any;
|
||||
seekBarTrackMesh?: any;
|
||||
volumeButtonCanvas?: HTMLCanvasElement;
|
||||
volumeButtonContext?: CanvasRenderingContext2D | null;
|
||||
volumeButtonMesh?: any;
|
||||
volumeButtonTexture?: any;
|
||||
};
|
||||
|
||||
const 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 DEFAULT_MEDIA_CAPABILITIES: MediaCapabilities = {
|
||||
audio: true,
|
||||
carousel: false,
|
||||
dynamicTexture: true,
|
||||
navigation: true,
|
||||
playback: true,
|
||||
timeline: true
|
||||
};
|
||||
|
||||
export function createVrControlPanel(
|
||||
scene: any,
|
||||
title: string,
|
||||
mediaCapabilities: MediaCapabilities = DEFAULT_MEDIA_CAPABILITIES
|
||||
): VrControlPanel {
|
||||
const group = createVrPanelGroup(scene);
|
||||
const interactables: any[] = [];
|
||||
|
||||
const panelMesh = createPanelBackground(title);
|
||||
group.add(panelMesh);
|
||||
interactables.push(panelMesh);
|
||||
|
||||
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);
|
||||
let seekBarTrackMesh;
|
||||
let seekBarProgressMesh;
|
||||
let seekBarHitAreaMesh;
|
||||
if (mediaCapabilities.timeline) {
|
||||
const seekBarMeshes = createSeekBarMeshes();
|
||||
seekBarTrackMesh = seekBarMeshes.trackMesh;
|
||||
seekBarProgressMesh = seekBarMeshes.progressMesh;
|
||||
seekBarHitAreaMesh = seekBarMeshes.hitAreaMesh;
|
||||
group.add(seekBarTrackMesh);
|
||||
group.add(seekBarProgressMesh);
|
||||
group.add(seekBarHitAreaMesh);
|
||||
interactables.push(seekBarHitAreaMesh);
|
||||
}
|
||||
|
||||
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);
|
||||
let playPauseButton: VrButtonTexture | undefined;
|
||||
let playPauseButtonMesh;
|
||||
let loopButton: VrButtonTexture | undefined;
|
||||
let loopButtonMesh;
|
||||
let rewindButtonMesh;
|
||||
let forwardButtonMesh;
|
||||
if (mediaCapabilities.playback) {
|
||||
playPauseButton = createVrButtonTexture();
|
||||
playPauseButtonMesh = createButtonMesh({
|
||||
...VR_PANEL_BUTTON_LAYOUTS.playPause,
|
||||
texture: playPauseButton.texture
|
||||
});
|
||||
group.add(playPauseButtonMesh);
|
||||
interactables.push(playPauseButtonMesh);
|
||||
|
||||
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);
|
||||
loopButton = createVrButtonTexture();
|
||||
updateLoopButtonTexture(loopButton, false);
|
||||
loopButtonMesh = createButtonMesh({
|
||||
...VR_PANEL_BUTTON_LAYOUTS.loop,
|
||||
texture: loopButton.texture
|
||||
});
|
||||
group.add(loopButtonMesh);
|
||||
interactables.push(loopButtonMesh);
|
||||
}
|
||||
|
||||
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);
|
||||
if (mediaCapabilities.navigation) {
|
||||
rewindButtonMesh = createButtonMesh({
|
||||
...VR_PANEL_BUTTON_LAYOUTS.rewind,
|
||||
texture: mediaCapabilities.carousel
|
||||
? createStaticVrButtonTexture('chevron-left')
|
||||
: createStaticVrButtonTexture('rotate-ccw', '15')
|
||||
});
|
||||
group.add(rewindButtonMesh);
|
||||
interactables.push(rewindButtonMesh);
|
||||
|
||||
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);
|
||||
forwardButtonMesh = createButtonMesh({
|
||||
...VR_PANEL_BUTTON_LAYOUTS.forward,
|
||||
texture: mediaCapabilities.carousel
|
||||
? createStaticVrButtonTexture('chevron-right')
|
||||
: createStaticVrButtonTexture('rotate-cw', '15')
|
||||
});
|
||||
group.add(forwardButtonMesh);
|
||||
interactables.push(forwardButtonMesh);
|
||||
}
|
||||
|
||||
const exitButtonMesh = createButtonMesh({
|
||||
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)
|
||||
...VR_PANEL_BUTTON_LAYOUTS.exit,
|
||||
texture: createStaticVrButtonTexture('arrow-left')
|
||||
});
|
||||
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);
|
||||
let volumeButton: VrButtonTexture | undefined;
|
||||
let volumeButtonMesh;
|
||||
if (mediaCapabilities.audio) {
|
||||
volumeButton = createVrButtonTexture();
|
||||
volumeButtonMesh = createButtonMesh({
|
||||
...VR_PANEL_BUTTON_LAYOUTS.volume,
|
||||
texture: volumeButton.texture
|
||||
});
|
||||
group.add(volumeButtonMesh);
|
||||
interactables.push(volumeButtonMesh);
|
||||
}
|
||||
|
||||
group.visible = false;
|
||||
|
||||
@@ -185,41 +147,47 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
|
||||
forwardButtonMesh,
|
||||
group,
|
||||
interactables,
|
||||
playPauseButtonCanvas,
|
||||
playPauseButtonContext,
|
||||
loopButtonCanvas: loopButton?.canvas,
|
||||
loopButtonContext: loopButton?.context,
|
||||
loopButtonMesh,
|
||||
loopButtonTexture: loopButton?.texture,
|
||||
playPauseButtonCanvas: playPauseButton?.canvas,
|
||||
playPauseButtonContext: playPauseButton?.context,
|
||||
playPauseButtonMesh,
|
||||
playPauseButtonTexture,
|
||||
playPauseButtonTexture: playPauseButton?.texture,
|
||||
rewindButtonMesh,
|
||||
seekBarHitAreaMesh,
|
||||
seekBarProgressMesh,
|
||||
seekBarTrackMesh,
|
||||
volumeButtonCanvas,
|
||||
volumeButtonContext,
|
||||
volumeButtonCanvas: volumeButton?.canvas,
|
||||
volumeButtonContext: volumeButton?.context,
|
||||
volumeButtonMesh,
|
||||
volumeButtonTexture
|
||||
volumeButtonTexture: volumeButton?.texture
|
||||
};
|
||||
}
|
||||
|
||||
export function updateVrPlayPauseButtonIcon(panel: VrControlPanel | undefined, isPausedOrEnded: boolean): void {
|
||||
if (!panel?.playPauseButtonContext || !panel.playPauseButtonTexture) return;
|
||||
updatePlayPauseButtonTexture({
|
||||
canvas: panel?.playPauseButtonCanvas,
|
||||
context: panel?.playPauseButtonContext,
|
||||
texture: panel?.playPauseButtonTexture
|
||||
}, isPausedOrEnded);
|
||||
}
|
||||
|
||||
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 updateVrLoopButtonIcon(panel: VrControlPanel | undefined, isLooping: boolean): void {
|
||||
updateLoopButtonTexture({
|
||||
canvas: panel?.loopButtonCanvas,
|
||||
context: panel?.loopButtonContext,
|
||||
texture: panel?.loopButtonTexture
|
||||
}, isLooping);
|
||||
}
|
||||
|
||||
export function updateVrVolumeButtonIcon(panel: VrControlPanel | undefined, isMuted: boolean): void {
|
||||
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;
|
||||
updateVolumeButtonTexture({
|
||||
canvas: panel?.volumeButtonCanvas,
|
||||
context: panel?.volumeButtonContext,
|
||||
texture: panel?.volumeButtonTexture
|
||||
}, isMuted);
|
||||
}
|
||||
|
||||
export function updateVrSeekBarAppearance(panel: VrControlPanel | undefined, progress: number | null): void {
|
||||
@@ -260,63 +228,3 @@ export function getSeekProgressFromIntersection(panel: VrControlPanel | undefine
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from './vr-control-panel.js';
|
||||
|
||||
type VrControllerSelectionOptions = {
|
||||
beginSeekDrag?: (controller: any) => void;
|
||||
exitVr: () => void;
|
||||
forward: () => void;
|
||||
hidePanel: () => void;
|
||||
@@ -13,6 +14,7 @@ type VrControllerSelectionOptions = {
|
||||
rewind: () => void;
|
||||
seek: (progress: number) => void;
|
||||
showPanel: () => void;
|
||||
toggleLoop: () => void;
|
||||
toggleMute: () => void;
|
||||
togglePlayPause: () => void;
|
||||
uiElements: any[];
|
||||
@@ -21,36 +23,11 @@ type VrControllerSelectionOptions = {
|
||||
|
||||
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);
|
||||
applySelectionRay(controller, options.raycaster);
|
||||
|
||||
const directIntersects = options.raycaster.intersectObjects(options.uiElements, true);
|
||||
if (directIntersects.length === 0) {
|
||||
@@ -91,9 +68,16 @@ export function handleVrControllerSelect(event: any, options: VrControllerSelect
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstIntersected.name === 'vrLoopButton') {
|
||||
options.toggleLoop();
|
||||
options.showPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstIntersected.name === 'seekBarHitArea') {
|
||||
options.showPanel();
|
||||
options.seek(getSeekProgressFromIntersection(options.vrPanel, intersectionPoint));
|
||||
options.beginSeekDrag?.(controller);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -107,3 +91,10 @@ function togglePanel(options: VrControllerSelectionOptions): void {
|
||||
options.showPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function applySelectionRay(controller: any, raycaster: any): void {
|
||||
controller.updateMatrixWorld();
|
||||
tempMatrix.identity().extractRotation(controller.matrixWorld);
|
||||
raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
|
||||
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
|
||||
}
|
||||
|
||||
123
src/vr180player/xr/vr-panel-button-textures.ts
Normal file
123
src/vr180player/xr/vr-panel-button-textures.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||
import { drawLucideIcon, type LucideIconName } from '../dom/icons.js';
|
||||
import { createLucideButtonTexture, drawRoundedRect } from '../rendering/three-utils.js';
|
||||
import {
|
||||
FIGMA_CORNER_RADIUS_PX,
|
||||
FIGMA_PANEL_HEIGHT_PX,
|
||||
FIGMA_PANEL_WIDTH_PX,
|
||||
FIGMA_TITLE_FONT_SIZE_PX,
|
||||
FIGMA_TITLE_MARGIN_TOP_PX,
|
||||
PANEL_TEXTURE_HEIGHT,
|
||||
PANEL_TEXTURE_WIDTH,
|
||||
VR_BUTTON_ICON_SIZE,
|
||||
VR_BUTTON_TEXTURE_SIZE
|
||||
} from './vr-panel-layout.js';
|
||||
|
||||
export type VrButtonTextureControls = {
|
||||
canvas?: HTMLCanvasElement;
|
||||
context?: CanvasRenderingContext2D | null;
|
||||
texture?: any;
|
||||
};
|
||||
|
||||
export type VrButtonTexture = {
|
||||
canvas: HTMLCanvasElement;
|
||||
context: CanvasRenderingContext2D | null;
|
||||
texture: any;
|
||||
};
|
||||
|
||||
export function createVrButtonTexture(): VrButtonTexture {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = VR_BUTTON_TEXTURE_SIZE;
|
||||
canvas.height = VR_BUTTON_TEXTURE_SIZE;
|
||||
const context = canvas.getContext('2d');
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
|
||||
return {
|
||||
canvas,
|
||||
context,
|
||||
texture
|
||||
};
|
||||
}
|
||||
|
||||
export function createStaticVrButtonTexture(iconName: LucideIconName, label?: string): any {
|
||||
return createLucideButtonTexture(iconName, '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, label);
|
||||
}
|
||||
|
||||
export function createPanelBackgroundTexture(title: string): any {
|
||||
const panelCanvas = document.createElement('canvas');
|
||||
panelCanvas.width = PANEL_TEXTURE_WIDTH;
|
||||
panelCanvas.height = PANEL_TEXTURE_HEIGHT;
|
||||
const panelCtx = panelCanvas.getContext('2d');
|
||||
|
||||
if (!panelCtx) {
|
||||
throw new Error('Unable to create 2D canvas context for VR control panel.');
|
||||
}
|
||||
|
||||
panelCtx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
const textureCornerRadius = FIGMA_CORNER_RADIUS_PX * (PANEL_TEXTURE_WIDTH / FIGMA_PANEL_WIDTH_PX);
|
||||
drawRoundedRect(panelCtx, 0, 0, PANEL_TEXTURE_WIDTH, PANEL_TEXTURE_HEIGHT, textureCornerRadius, true, false);
|
||||
|
||||
const titleFontSizeTexturePx = Math.round(FIGMA_TITLE_FONT_SIZE_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX));
|
||||
panelCtx.fillStyle = '#ffffff';
|
||||
panelCtx.font = `500 ${titleFontSizeTexturePx}px Helvetica, Arial, sans-serif`;
|
||||
panelCtx.textAlign = 'center';
|
||||
panelCtx.textBaseline = 'top';
|
||||
const titleMarginTopTexturePx = FIGMA_TITLE_MARGIN_TOP_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX);
|
||||
panelCtx.fillText(title, PANEL_TEXTURE_WIDTH / 2, titleMarginTopTexturePx);
|
||||
|
||||
const panelTexture = new THREE.CanvasTexture(panelCanvas);
|
||||
panelTexture.minFilter = THREE.LinearFilter;
|
||||
panelTexture.needsUpdate = true;
|
||||
return panelTexture;
|
||||
}
|
||||
|
||||
export function updatePlayPauseButtonTexture(
|
||||
controls: VrButtonTextureControls,
|
||||
isPausedOrEnded: boolean
|
||||
): void {
|
||||
if (!controls.context || !controls.canvas || !controls.texture) return;
|
||||
|
||||
const { canvas, context, texture } = controls;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
||||
drawLucideIcon(context, isPausedOrEnded ? 'play' : 'pause', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function updateLoopButtonTexture(
|
||||
controls: VrButtonTextureControls,
|
||||
isLooping: boolean
|
||||
): void {
|
||||
if (!controls.context || !controls.canvas || !controls.texture) return;
|
||||
|
||||
drawVrLoopButtonIcon(controls.context, controls.canvas, isLooping);
|
||||
controls.texture.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function updateVolumeButtonTexture(
|
||||
controls: VrButtonTextureControls,
|
||||
isMuted: boolean
|
||||
): void {
|
||||
if (!controls.context || !controls.canvas || !controls.texture) return;
|
||||
|
||||
const { canvas, context, texture } = controls;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
||||
drawLucideIcon(context, isMuted ? 'volume-x' : 'volume-2', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
|
||||
function drawVrLoopButtonIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
isLooping: boolean
|
||||
): void {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (isLooping) {
|
||||
drawRoundedRect(ctx, 10, 10, canvas.width - 20, canvas.height - 20, 28, 'rgba(125, 211, 252, 0.24)', false);
|
||||
}
|
||||
|
||||
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
||||
drawLucideIcon(ctx, 'repeat', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, isLooping ? '#7dd3fc' : '#ffffff', 2);
|
||||
}
|
||||
74
src/vr180player/xr/vr-panel-layout.ts
Normal file
74
src/vr180player/xr/vr-panel-layout.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export type VrPanelButtonLayout = {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
name: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export const FIGMA_PANEL_WIDTH_PX = 450;
|
||||
export const FIGMA_PANEL_HEIGHT_PX = 132;
|
||||
export const FIGMA_CORNER_RADIUS_PX = 30;
|
||||
export const FIGMA_TITLE_FONT_SIZE_PX = 14;
|
||||
export const FIGMA_TITLE_MARGIN_TOP_PX = 20;
|
||||
export const FIGMA_SEEK_BAR_WIDTH_PX = 386;
|
||||
export const FIGMA_SEEK_BAR_HEIGHT_PX = 5;
|
||||
export const FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX = (FIGMA_PANEL_HEIGHT_PX / 2) - 54;
|
||||
|
||||
export const WORLD_PANEL_WIDTH = 1.5;
|
||||
export const SCALE_FACTOR = WORLD_PANEL_WIDTH / FIGMA_PANEL_WIDTH_PX;
|
||||
export const WORLD_PANEL_HEIGHT = FIGMA_PANEL_HEIGHT_PX * SCALE_FACTOR;
|
||||
export const WORLD_SEEK_BAR_WIDTH = FIGMA_SEEK_BAR_WIDTH_PX * SCALE_FACTOR;
|
||||
export const WORLD_SEEK_BAR_TRACK_HEIGHT = FIGMA_SEEK_BAR_HEIGHT_PX * SCALE_FACTOR;
|
||||
export const WORLD_SEEK_BAR_PROGRESS_HEIGHT = (FIGMA_SEEK_BAR_HEIGHT_PX - 1) * SCALE_FACTOR;
|
||||
export const WORLD_SEEK_BAR_Y_OFFSET = FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX * SCALE_FACTOR;
|
||||
export const WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER = 12;
|
||||
|
||||
export const PANEL_TEXTURE_WIDTH = 1024;
|
||||
export const PANEL_TEXTURE_HEIGHT = Math.round(PANEL_TEXTURE_WIDTH * (FIGMA_PANEL_HEIGHT_PX / FIGMA_PANEL_WIDTH_PX));
|
||||
export const VR_BUTTON_TEXTURE_SIZE = 128;
|
||||
export const VR_BUTTON_ICON_SIZE = 82;
|
||||
|
||||
export const VR_PANEL_POSITION = {
|
||||
x: 0,
|
||||
y: 0.5,
|
||||
z: -1.8
|
||||
};
|
||||
|
||||
export const VR_PANEL_BUTTON_LAYOUTS = {
|
||||
exit: {
|
||||
centerX: 42,
|
||||
centerY: 90,
|
||||
name: 'vrExitButton',
|
||||
size: 44
|
||||
},
|
||||
forward: {
|
||||
centerX: 281,
|
||||
centerY: 90,
|
||||
name: 'vrForwardButton',
|
||||
size: 44
|
||||
},
|
||||
loop: {
|
||||
centerX: 352,
|
||||
centerY: 90,
|
||||
name: 'vrLoopButton',
|
||||
size: 44
|
||||
},
|
||||
playPause: {
|
||||
centerX: 225,
|
||||
centerY: 90,
|
||||
name: 'vrPlayPauseButton',
|
||||
size: 44
|
||||
},
|
||||
rewind: {
|
||||
centerX: 169,
|
||||
centerY: 90,
|
||||
name: 'vrRewindButton',
|
||||
size: 44
|
||||
},
|
||||
volume: {
|
||||
centerX: 408,
|
||||
centerY: 90,
|
||||
name: 'vrVolumeButton',
|
||||
size: 44
|
||||
}
|
||||
} satisfies Record<string, VrPanelButtonLayout>;
|
||||
106
src/vr180player/xr/vr-panel-meshes.ts
Normal file
106
src/vr180player/xr/vr-panel-meshes.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||
import {
|
||||
FIGMA_PANEL_HEIGHT_PX,
|
||||
FIGMA_PANEL_WIDTH_PX,
|
||||
SCALE_FACTOR,
|
||||
VR_PANEL_POSITION,
|
||||
WORLD_PANEL_HEIGHT,
|
||||
WORLD_PANEL_WIDTH,
|
||||
WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER,
|
||||
WORLD_SEEK_BAR_PROGRESS_HEIGHT,
|
||||
WORLD_SEEK_BAR_TRACK_HEIGHT,
|
||||
WORLD_SEEK_BAR_WIDTH,
|
||||
WORLD_SEEK_BAR_Y_OFFSET,
|
||||
type VrPanelButtonLayout
|
||||
} from './vr-panel-layout.js';
|
||||
import { createPanelBackgroundTexture } from './vr-panel-button-textures.js';
|
||||
|
||||
export type ButtonMeshOptions = VrPanelButtonLayout & {
|
||||
texture: any;
|
||||
};
|
||||
|
||||
export type SeekBarMeshes = {
|
||||
hitAreaMesh: any;
|
||||
progressMesh: any;
|
||||
trackMesh: any;
|
||||
};
|
||||
|
||||
export function createVrPanelGroup(scene: any): any {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(VR_PANEL_POSITION.x, VR_PANEL_POSITION.y, VR_PANEL_POSITION.z);
|
||||
group.rotation.x = 0;
|
||||
scene.add(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function createPanelBackground(title: string): any {
|
||||
const panelMaterial = new THREE.MeshBasicMaterial({
|
||||
map: createPanelBackgroundTexture(title),
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false
|
||||
});
|
||||
const panelGeometry = new THREE.PlaneGeometry(WORLD_PANEL_WIDTH, WORLD_PANEL_HEIGHT);
|
||||
const panelMesh = new THREE.Mesh(panelGeometry, panelMaterial);
|
||||
panelMesh.name = 'vrControlPanelBackground';
|
||||
panelMesh.renderOrder = 0;
|
||||
|
||||
return panelMesh;
|
||||
}
|
||||
|
||||
export function createSeekBarMeshes(): SeekBarMeshes {
|
||||
const trackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 });
|
||||
const trackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
|
||||
const trackMesh = new THREE.Mesh(trackGeometry, trackMaterial);
|
||||
trackMesh.name = 'seekBarTrackVisual';
|
||||
trackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
||||
trackMesh.position.z = 0.01;
|
||||
trackMesh.renderOrder = 1;
|
||||
|
||||
const progressMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 });
|
||||
const progressGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_PROGRESS_HEIGHT);
|
||||
const progressMesh = new THREE.Mesh(progressGeometry, progressMaterial);
|
||||
progressMesh.name = 'seekBarProgressVisual';
|
||||
progressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + SCALE_FACTOR;
|
||||
progressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
|
||||
progressMesh.position.z = 0.015;
|
||||
progressMesh.scale.x = 0.001;
|
||||
progressMesh.renderOrder = 2;
|
||||
|
||||
const hitAreaGeometry = new THREE.PlaneGeometry(
|
||||
WORLD_SEEK_BAR_WIDTH,
|
||||
WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER
|
||||
);
|
||||
const hitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 });
|
||||
const hitAreaMesh = new THREE.Mesh(hitAreaGeometry, hitAreaMaterial);
|
||||
hitAreaMesh.name = 'seekBarHitArea';
|
||||
hitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
||||
hitAreaMesh.position.z = 0.012;
|
||||
hitAreaMesh.renderOrder = 2;
|
||||
|
||||
return {
|
||||
hitAreaMesh,
|
||||
progressMesh,
|
||||
trackMesh
|
||||
};
|
||||
}
|
||||
|
||||
export function createButtonMesh({ centerX, centerY, name, size, texture }: ButtonMeshOptions): any {
|
||||
const buttonWorldSize = size * SCALE_FACTOR;
|
||||
const buttonMaterial = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false
|
||||
});
|
||||
const buttonGeometry = new THREE.PlaneGeometry(buttonWorldSize, buttonWorldSize);
|
||||
const buttonMesh = new THREE.Mesh(buttonGeometry, buttonMaterial);
|
||||
buttonMesh.name = name;
|
||||
buttonMesh.renderOrder = 3;
|
||||
|
||||
const worldButtonOffsetX = (centerX - FIGMA_PANEL_WIDTH_PX / 2) * SCALE_FACTOR;
|
||||
const worldButtonOffsetY = -(centerY - FIGMA_PANEL_HEIGHT_PX / 2) * SCALE_FACTOR;
|
||||
buttonMesh.position.set(worldButtonOffsetX, worldButtonOffsetY, 0.02);
|
||||
|
||||
return buttonMesh;
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
setVrPanelOpacity,
|
||||
type VrControlPanel
|
||||
} from './vr-control-panel.js';
|
||||
import { DEFAULT_MENU_AUTO_HIDE_DELAY_MS } from '../utils/control-panel-timing.js';
|
||||
|
||||
const FADE_DURATION_MS = 200;
|
||||
const AUTO_HIDE_DELAY_MS = 10000;
|
||||
|
||||
export class VrPanelVisibility {
|
||||
private hideTimeout: number | undefined;
|
||||
@@ -28,7 +28,15 @@ export class VrPanelVisibility {
|
||||
this.hideImmediately();
|
||||
}
|
||||
|
||||
show(): void {
|
||||
show(autoHideDelayMs = DEFAULT_MENU_AUTO_HIDE_DELAY_MS): void {
|
||||
this.showWithAutoHide(true, autoHideDelayMs);
|
||||
}
|
||||
|
||||
showPersistent(): void {
|
||||
this.showWithAutoHide(false, DEFAULT_MENU_AUTO_HIDE_DELAY_MS);
|
||||
}
|
||||
|
||||
private showWithAutoHide(shouldAutoHide: boolean, autoHideDelayMs: number): void {
|
||||
if (this.panel) this.panel.group.visible = true;
|
||||
this.clearHideTimeout();
|
||||
|
||||
@@ -37,7 +45,9 @@ export class VrPanelVisibility {
|
||||
this.startFade();
|
||||
}
|
||||
|
||||
this.hideTimeout = window.setTimeout(() => this.hide(), AUTO_HIDE_DELAY_MS);
|
||||
if (shouldAutoHide) {
|
||||
this.hideTimeout = window.setTimeout(() => this.hide(), autoHideDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
|
||||
53
src/vr180player/xr/xr-support.ts
Normal file
53
src/vr180player/xr/xr-support.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
let immersiveVrSupportPromise: Promise<boolean> | undefined;
|
||||
|
||||
export function getImmersiveVrSupport(): Promise<boolean> {
|
||||
if (!immersiveVrSupportPromise) {
|
||||
immersiveVrSupportPromise = checkImmersiveVrSupport();
|
||||
}
|
||||
|
||||
return immersiveVrSupportPromise;
|
||||
}
|
||||
|
||||
export function applyKnownImmersiveVrSupport(playButton: HTMLButtonElement, supported: boolean): void {
|
||||
playButton.dataset.xrSupported = supported ? 'true' : 'false';
|
||||
|
||||
if (!supported) {
|
||||
playButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyImmersiveVrSupportToButton(playButton: HTMLButtonElement): Promise<boolean> {
|
||||
const supported = await getImmersiveVrSupport();
|
||||
applyKnownImmersiveVrSupport(playButton, supported);
|
||||
|
||||
if (!supported) {
|
||||
logImmersiveVrUnsupported();
|
||||
}
|
||||
|
||||
return supported;
|
||||
}
|
||||
|
||||
function checkImmersiveVrSupport(): Promise<boolean> {
|
||||
if (!navigator.xr) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
return navigator.xr.isSessionSupported('immersive-vr').catch((err) => {
|
||||
console.error('XR Support Check Error:', err);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function logImmersiveVrUnsupported(): void {
|
||||
if (!navigator.xr) {
|
||||
if (!window.isSecureContext) {
|
||||
console.warn('VR_WEB_PLAYER_XR: Immersive WebXR requires a secure context. Serve the page over HTTPS, a trusted tunnel, or a deployed CDN URL to test in a headset.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('VR_WEB_PLAYER_XR: navigator.xr is not available in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('VR_WEB_PLAYER_XR: immersive-vr is not supported by this browser/device.');
|
||||
}
|
||||
31
test-pages/demo-xr-status.js
Normal file
31
test-pages/demo-xr-status.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const statusElement = document.querySelector('[data-demo-xr-status]');
|
||||
|
||||
if (statusElement) {
|
||||
updateXrStatus(statusElement);
|
||||
}
|
||||
|
||||
async function updateXrStatus(element) {
|
||||
if (!window.isSecureContext) {
|
||||
element.textContent = 'Immersive WebXR is blocked on this origin. Use HTTPS, a trusted tunnel, or a deployed CDN URL for headset testing.';
|
||||
element.dataset.state = 'blocked';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.xr) {
|
||||
element.textContent = 'Immersive WebXR is unavailable in this browser.';
|
||||
element.dataset.state = 'blocked';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const supported = await navigator.xr.isSessionSupported('immersive-vr');
|
||||
element.textContent = supported
|
||||
? 'Immersive WebXR is available. Use the player button to enter VR.'
|
||||
: 'This browser reports that immersive-vr is not supported.';
|
||||
element.dataset.state = supported ? 'ready' : 'blocked';
|
||||
} catch (error) {
|
||||
element.textContent = 'Unable to check immersive-vr support. See the browser console for details.';
|
||||
element.dataset.state = 'blocked';
|
||||
console.error('DEMO_XR_STATUS_ERROR:', error);
|
||||
}
|
||||
}
|
||||
293
test-pages/demo.css
Normal file
293
test-pages/demo.css
Normal file
@@ -0,0 +1,293 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: #151515;
|
||||
background: #f4f4f2;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.demo-page {
|
||||
min-height: 100vh;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.demo-shell {
|
||||
width: min(100%, 1040px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.demo-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.demo-brand {
|
||||
margin: 0;
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.demo-kicker {
|
||||
margin: 8px 0 0;
|
||||
max-width: 700px;
|
||||
color: #555;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.demo-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #c7c7c0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-height: 150px;
|
||||
padding: 18px;
|
||||
border: 1px solid #d5d5cf;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
text-decoration: none;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.demo-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: #83837a;
|
||||
}
|
||||
|
||||
.demo-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.demo-card p,
|
||||
.demo-meta {
|
||||
margin: 0;
|
||||
color: #5c5c55;
|
||||
}
|
||||
|
||||
.demo-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
color: #66665f;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.demo-player {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.demo-player-frame {
|
||||
width: min(100%, 960px);
|
||||
}
|
||||
|
||||
.demo-note {
|
||||
max-width: 760px;
|
||||
margin: 18px 0 0;
|
||||
color: #606058;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.demo-xr-status {
|
||||
max-width: 760px;
|
||||
margin: 0 0 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d5d5cf;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #4f4f48;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.demo-xr-status[data-state="blocked"] {
|
||||
border-color: #d7a13a;
|
||||
background: #fff8e8;
|
||||
color: #5f4515;
|
||||
}
|
||||
|
||||
.demo-xr-status[data-state="ready"] {
|
||||
border-color: #7eb07b;
|
||||
background: #eff8ef;
|
||||
color: #275425;
|
||||
}
|
||||
|
||||
.demo-gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.demo-gallery-tile {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 0 0 12px;
|
||||
border: 1px solid #d5d5cf;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.demo-gallery-tile:hover {
|
||||
border-color: #83837a;
|
||||
}
|
||||
|
||||
.demo-gallery-tile img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.demo-gallery-tile span {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.demo-gallery-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #151515;
|
||||
color: #fff;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.demo-local-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
width: min(100%, 760px);
|
||||
padding: 18px;
|
||||
border: 1px solid #d5d5cf;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.demo-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.demo-field input,
|
||||
.demo-field select {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border: 1px solid #c7c7c0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.demo-field input {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.demo-field select {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.demo-local-name {
|
||||
margin: 0;
|
||||
color: #606058;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.demo-local-preview {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 6px;
|
||||
background: #111;
|
||||
color: #d8d8d8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-local-preview img,
|
||||
.demo-local-preview video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.demo-local-preview p {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.demo-local-launch {
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #151515;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.demo-local-launch:disabled {
|
||||
background: #9a9a92;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.demo-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.demo-topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.demo-brand {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
60
test-pages/index.html
Normal file
60
test-pages/index.html
Normal file
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>VR Web Player Test Pages</title>
|
||||
<link rel="stylesheet" href="./demo.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="demo-page">
|
||||
<div class="demo-shell">
|
||||
<header class="demo-topbar">
|
||||
<div>
|
||||
<h1 class="demo-brand">VR Web Player Tests</h1>
|
||||
<p class="demo-kicker">Pick local media, or launch one of the two bundled SBS image samples.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
||||
|
||||
<div class="demo-gallery-grid" aria-label="Launcher gallery">
|
||||
<a class="demo-gallery-tile demo-gallery-link" href="./test-local-media.html">
|
||||
<div class="demo-gallery-placeholder">Local</div>
|
||||
<span>Local Image or Video</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="demo-gallery-tile"
|
||||
data-vr-web-launcher
|
||||
data-media-type="video"
|
||||
data-projection="plane"
|
||||
data-src="../media/3d_sbs_test.mp4"
|
||||
data-title="SBS 3D Image"
|
||||
data-crossorigin="anonymous">
|
||||
<img src="../media/169_3d_test.png" alt="SBS 3D Image">
|
||||
<span>SBS 3D Image</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="demo-gallery-tile"
|
||||
data-vr-web-launcher
|
||||
data-media-type="image"
|
||||
data-projection="vr180"
|
||||
data-src="../media/StormTrooper_VR.webp"
|
||||
data-title="SBS VR180 Image"
|
||||
data-crossorigin="anonymous">
|
||||
<img src="../media/StormTrooper_VR.webp" alt="SBS VR180 Image">
|
||||
<span>SBS VR180 Image</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="demo-note">Bundled image tests use files in <code>../media/</code>. The local media page uses browser-selected files from this device.</p>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="./demo-xr-status.js"></script>
|
||||
<script type="module" src="../vr180player/vr180-player.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
140
test-pages/local-media-picker.js
Normal file
140
test-pages/local-media-picker.js
Normal file
@@ -0,0 +1,140 @@
|
||||
const fileInput = document.querySelector('[data-local-media-file]');
|
||||
const launchButton = document.querySelector('[data-local-media-launch]');
|
||||
const projectionSelect = document.querySelector('[data-local-media-projection]');
|
||||
const preview = document.querySelector('[data-local-media-preview]');
|
||||
const fileName = document.querySelector('[data-local-media-name]');
|
||||
|
||||
let activeObjectUrl = '';
|
||||
|
||||
fileInput?.addEventListener('change', () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (!file) {
|
||||
clearLocalMedia();
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaType = getMediaType(file);
|
||||
if (!mediaType) {
|
||||
clearLocalMedia();
|
||||
setPreviewMessage('Choose an image or video file.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeObjectUrl) {
|
||||
URL.revokeObjectURL(activeObjectUrl);
|
||||
}
|
||||
|
||||
activeObjectUrl = URL.createObjectURL(file);
|
||||
updateLauncher(file, mediaType, activeObjectUrl);
|
||||
renderPreview(file, mediaType, activeObjectUrl);
|
||||
});
|
||||
|
||||
projectionSelect?.addEventListener('change', () => {
|
||||
if (launchButton && projectionSelect) {
|
||||
launchButton.dataset.projection = projectionSelect.value;
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
if (activeObjectUrl) {
|
||||
URL.revokeObjectURL(activeObjectUrl);
|
||||
}
|
||||
});
|
||||
|
||||
function clearLocalMedia() {
|
||||
if (activeObjectUrl) {
|
||||
URL.revokeObjectURL(activeObjectUrl);
|
||||
activeObjectUrl = '';
|
||||
}
|
||||
|
||||
if (launchButton) {
|
||||
launchButton.disabled = true;
|
||||
delete launchButton.dataset.src;
|
||||
delete launchButton.dataset.mediaType;
|
||||
delete launchButton.dataset.type;
|
||||
delete launchButton.dataset.title;
|
||||
}
|
||||
|
||||
if (fileName) {
|
||||
fileName.textContent = 'No file selected';
|
||||
}
|
||||
|
||||
setPreviewMessage('Preview will appear here.');
|
||||
}
|
||||
|
||||
function getMediaType(file) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (file.type.startsWith('video/')) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||
if (['avif', 'gif', 'jpeg', 'jpg', 'png', 'webp'].includes(extension)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (['m4v', 'mov', 'mp4', 'ogv', 'webm'].includes(extension)) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function updateLauncher(file, mediaType, objectUrl) {
|
||||
if (!launchButton || !projectionSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
launchButton.disabled = false;
|
||||
launchButton.dataset.src = objectUrl;
|
||||
launchButton.dataset.mediaType = mediaType;
|
||||
launchButton.dataset.projection = projectionSelect.value;
|
||||
launchButton.dataset.title = file.name;
|
||||
|
||||
if (file.type) {
|
||||
launchButton.dataset.type = file.type;
|
||||
} else {
|
||||
delete launchButton.dataset.type;
|
||||
}
|
||||
|
||||
if (fileName) {
|
||||
fileName.textContent = `${file.name} (${mediaType})`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPreview(file, mediaType, objectUrl) {
|
||||
if (!preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
preview.replaceChildren();
|
||||
|
||||
if (mediaType === 'image') {
|
||||
const image = document.createElement('img');
|
||||
image.src = objectUrl;
|
||||
image.alt = file.name;
|
||||
preview.appendChild(image);
|
||||
return;
|
||||
}
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.src = objectUrl;
|
||||
video.controls = true;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
preview.appendChild(video);
|
||||
}
|
||||
|
||||
function setPreviewMessage(message) {
|
||||
if (!preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const placeholder = document.createElement('p');
|
||||
placeholder.textContent = message;
|
||||
preview.replaceChildren(placeholder);
|
||||
}
|
||||
58
test-pages/test-local-media.html
Normal file
58
test-pages/test-local-media.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Local Media Test</title>
|
||||
<link rel="stylesheet" href="./demo.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="demo-page">
|
||||
<div class="demo-shell demo-player">
|
||||
<header class="demo-topbar">
|
||||
<div>
|
||||
<h1 class="demo-brand">Local Media</h1>
|
||||
<p class="demo-kicker">Select a local SBS image or video, choose the projection, then launch it.</p>
|
||||
</div>
|
||||
<a class="demo-back" href="./index.html">Back</a>
|
||||
</header>
|
||||
|
||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
||||
|
||||
<section class="demo-local-panel" aria-label="Local media launcher">
|
||||
<label class="demo-field">
|
||||
<span>Media file</span>
|
||||
<input data-local-media-file type="file" accept="image/*,video/*">
|
||||
</label>
|
||||
|
||||
<label class="demo-field">
|
||||
<span>Projection</span>
|
||||
<select data-local-media-projection>
|
||||
<option value="plane">3D plane</option>
|
||||
<option value="vr180">VR180 3D</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<p class="demo-local-name" data-local-media-name>No file selected</p>
|
||||
|
||||
<div class="demo-local-preview" data-local-media-preview>
|
||||
<p>Preview will appear here.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="demo-local-launch"
|
||||
data-vr-web-launcher
|
||||
data-local-media-launch
|
||||
data-projection="plane"
|
||||
disabled>
|
||||
Launch selected media
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="./demo-xr-status.js"></script>
|
||||
<script type="module" src="./local-media-picker.js"></script>
|
||||
<script type="module" src="../vr180player/vr180-player.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
44
tests/control-panel-timing.test.mjs
Normal file
44
tests/control-panel-timing.test.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
DEFAULT_MENU_AUTO_HIDE_DELAY_MS,
|
||||
PLAYING_VIDEO_MENU_AUTO_HIDE_DELAY_MS,
|
||||
getVideoAwareAutoHideDelayMs,
|
||||
isVideoActivelyPlaying
|
||||
} from '../vr180player/utils/control-panel-timing.js';
|
||||
|
||||
test('isVideoActivelyPlaying only returns true for non-paused, non-ended video state', () => {
|
||||
assert.equal(isVideoActivelyPlaying({ paused: false, ended: false }), true);
|
||||
assert.equal(isVideoActivelyPlaying({ paused: true, ended: false }), false);
|
||||
assert.equal(isVideoActivelyPlaying({ paused: false, ended: true }), false);
|
||||
assert.equal(isVideoActivelyPlaying(undefined), false);
|
||||
});
|
||||
|
||||
test('getVideoAwareAutoHideDelayMs uses the shorter delay while video is playing', () => {
|
||||
assert.equal(
|
||||
getVideoAwareAutoHideDelayMs({ paused: false, ended: false }),
|
||||
PLAYING_VIDEO_MENU_AUTO_HIDE_DELAY_MS
|
||||
);
|
||||
assert.equal(
|
||||
getVideoAwareAutoHideDelayMs({ paused: true, ended: false }),
|
||||
DEFAULT_MENU_AUTO_HIDE_DELAY_MS
|
||||
);
|
||||
});
|
||||
|
||||
test('getVideoAwareAutoHideDelayMs accepts custom delay values for other control surfaces', () => {
|
||||
assert.equal(
|
||||
getVideoAwareAutoHideDelayMs(
|
||||
{ paused: false, ended: false },
|
||||
{ idleDelayMs: 3000, playingDelayMs: 1500 }
|
||||
),
|
||||
1500
|
||||
);
|
||||
assert.equal(
|
||||
getVideoAwareAutoHideDelayMs(
|
||||
{ paused: true, ended: false },
|
||||
{ idleDelayMs: 3000, playingDelayMs: 1500 }
|
||||
),
|
||||
3000
|
||||
);
|
||||
});
|
||||
67
tests/icons.test.mjs
Normal file
67
tests/icons.test.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { drawLucideIcon } from '../vr180player/dom/icons.js';
|
||||
|
||||
function createCanvasContextRecorder() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
beginPath() {
|
||||
calls.push(['beginPath']);
|
||||
},
|
||||
closePath() {
|
||||
calls.push(['closePath']);
|
||||
},
|
||||
lineTo(x, y) {
|
||||
calls.push(['lineTo', x, y]);
|
||||
},
|
||||
moveTo(x, y) {
|
||||
calls.push(['moveTo', x, y]);
|
||||
},
|
||||
restore() {
|
||||
calls.push(['restore']);
|
||||
},
|
||||
save() {
|
||||
calls.push(['save']);
|
||||
},
|
||||
scale(x, y) {
|
||||
calls.push(['scale', x, y]);
|
||||
},
|
||||
stroke() {
|
||||
calls.push(['stroke']);
|
||||
},
|
||||
translate(x, y) {
|
||||
calls.push(['translate', x, y]);
|
||||
},
|
||||
set lineCap(value) {
|
||||
calls.push(['lineCap', value]);
|
||||
},
|
||||
set lineJoin(value) {
|
||||
calls.push(['lineJoin', value]);
|
||||
},
|
||||
set lineWidth(value) {
|
||||
calls.push(['lineWidth', value]);
|
||||
},
|
||||
set strokeStyle(value) {
|
||||
calls.push(['strokeStyle', value]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('drawLucideIcon renders space-separated polygon points for the play icon', () => {
|
||||
const ctx = createCanvasContextRecorder();
|
||||
|
||||
drawLucideIcon(ctx, 'play', 0, 0, 24);
|
||||
|
||||
assert.deepEqual(
|
||||
ctx.calls.filter(([name]) => name === 'moveTo' || name === 'lineTo' || name === 'closePath'),
|
||||
[
|
||||
['moveTo', 6, 3],
|
||||
['lineTo', 20, 12],
|
||||
['lineTo', 6, 21],
|
||||
['lineTo', 6, 3],
|
||||
['closePath']
|
||||
]
|
||||
);
|
||||
});
|
||||
63
tests/input-mode.test.mjs
Normal file
63
tests/input-mode.test.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getPointerInputMode,
|
||||
rememberPointerInputMode
|
||||
} from '../vr180player/xr/input-mode.js';
|
||||
|
||||
test('getPointerInputMode ignores WebXR hand sources', () => {
|
||||
assert.equal(getPointerInputMode({ hand: {} }), null);
|
||||
assert.equal(getPointerInputMode({ profiles: ['generic-hand-tracking'] }), null);
|
||||
assert.equal(getPointerInputMode({ profiles: ['Oculus-Hand'] }), null);
|
||||
});
|
||||
|
||||
test('getPointerInputMode detects controller sources', () => {
|
||||
assert.equal(getPointerInputMode({ gamepad: {} }), 'controller');
|
||||
assert.equal(getPointerInputMode({ targetRayMode: 'tracked-pointer' }), 'controller');
|
||||
});
|
||||
|
||||
test('getPointerInputMode returns null for unknown or gaze-like sources', () => {
|
||||
assert.equal(getPointerInputMode(null), null);
|
||||
assert.equal(getPointerInputMode(undefined), null);
|
||||
assert.equal(getPointerInputMode({ profiles: ['generic-trigger'] }), null);
|
||||
assert.equal(getPointerInputMode({ targetRayMode: 'gaze' }), null);
|
||||
});
|
||||
|
||||
test('rememberPointerInputMode reads input sources from supported event shapes', () => {
|
||||
const fromNestedInputSource = {};
|
||||
rememberPointerInputMode(fromNestedInputSource, { data: { inputSource: { gamepad: {} } } }, 'controller');
|
||||
assert.equal(fromNestedInputSource.pointerInputMode, 'controller');
|
||||
|
||||
const fromDirectInputSource = {};
|
||||
rememberPointerInputMode(fromDirectInputSource, { inputSource: { gamepad: {} } }, 'controller');
|
||||
assert.equal(fromDirectInputSource.pointerInputMode, 'controller');
|
||||
|
||||
const fromDataSource = {};
|
||||
rememberPointerInputMode(fromDataSource, { data: { targetRayMode: 'tracked-pointer' } }, 'controller');
|
||||
assert.equal(fromDataSource.pointerInputMode, 'controller');
|
||||
});
|
||||
|
||||
test('rememberPointerInputMode keeps fallback mode when the event is ambiguous', () => {
|
||||
const inputSource = { pointerInputMode: 'controller' };
|
||||
|
||||
rememberPointerInputMode(inputSource, { data: { targetRayMode: 'gaze' } }, 'controller');
|
||||
|
||||
assert.equal(inputSource.pointerInputMode, 'controller');
|
||||
});
|
||||
|
||||
test('rememberPointerInputMode stores the input source on controller userData', () => {
|
||||
const inputSource = {
|
||||
controller: {
|
||||
userData: {
|
||||
existing: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rememberPointerInputMode(inputSource, { data: { inputSource: { gamepad: {} } } }, 'controller');
|
||||
|
||||
assert.equal(inputSource.pointerInputMode, 'controller');
|
||||
assert.equal(inputSource.controller.userData.existing, true);
|
||||
assert.equal(inputSource.controller.userData.vrwpInputSource, inputSource);
|
||||
});
|
||||
108
tests/launcher.test.mjs
Normal file
108
tests/launcher.test.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getLauncherAction,
|
||||
inferLauncherMediaType,
|
||||
readLauncherMediaConfig
|
||||
} from '../vr180player/launcher/launcher-config.js';
|
||||
|
||||
function createLauncher({ attributes = {}, dataset = {} } = {}) {
|
||||
return {
|
||||
dataset,
|
||||
getAttribute(name) {
|
||||
return attributes[name] ?? '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('getLauncherAction opens immersive only when immersive VR is supported', () => {
|
||||
assert.equal(getLauncherAction(true), 'immersive');
|
||||
assert.equal(getLauncherAction(false), 'fallback-modal');
|
||||
});
|
||||
|
||||
test('inferLauncherMediaType detects common image and video extensions', () => {
|
||||
assert.equal(inferLauncherMediaType('https://cdn.example.com/demo.png'), 'image');
|
||||
assert.equal(inferLauncherMediaType('/media/demo.JPG?cache=1'), 'image');
|
||||
assert.equal(inferLauncherMediaType('/media/demo.webm'), 'video');
|
||||
assert.equal(inferLauncherMediaType('/media/demo.mp4#clip'), 'video');
|
||||
assert.equal(inferLauncherMediaType('/media/demo'), null);
|
||||
});
|
||||
|
||||
test('readLauncherMediaConfig reads explicit launcher data', () => {
|
||||
const config = readLauncherMediaConfig(createLauncher({
|
||||
dataset: {
|
||||
crossorigin: 'anonymous',
|
||||
headLock: 'position',
|
||||
mediaType: 'video',
|
||||
poster: '/poster.jpg',
|
||||
preload: 'auto',
|
||||
projection: 'plane',
|
||||
src: '/media/demo',
|
||||
title: 'Demo Video',
|
||||
type: 'video/mp4'
|
||||
}
|
||||
}));
|
||||
|
||||
assert.deepEqual(config, {
|
||||
carousel: false,
|
||||
crossOrigin: 'anonymous',
|
||||
headLockMode: 'position',
|
||||
mediaType: 'video',
|
||||
poster: '/poster.jpg',
|
||||
preload: 'auto',
|
||||
projectionMode: 'plane',
|
||||
src: '/media/demo',
|
||||
srcs: ['/media/demo'],
|
||||
title: 'Demo Video',
|
||||
type: 'video/mp4'
|
||||
});
|
||||
});
|
||||
|
||||
test('readLauncherMediaConfig infers defaults from data-src and aria-label', () => {
|
||||
const config = readLauncherMediaConfig(createLauncher({
|
||||
attributes: {
|
||||
'aria-label': 'Demo Image'
|
||||
},
|
||||
dataset: {
|
||||
src: '/media/demo-image.webp'
|
||||
}
|
||||
}));
|
||||
|
||||
assert.equal(config.mediaType, 'image');
|
||||
assert.equal(config.projectionMode, 'vr180');
|
||||
assert.equal(config.headLockMode, 'auto');
|
||||
assert.equal(config.preload, 'metadata');
|
||||
assert.equal(config.carousel, false);
|
||||
assert.deepEqual(config.srcs, ['/media/demo-image.webp']);
|
||||
assert.equal(config.title, 'Demo Image');
|
||||
});
|
||||
|
||||
test('readLauncherMediaConfig supports image carousel launchers', () => {
|
||||
const config = readLauncherMediaConfig(createLauncher({
|
||||
dataset: {
|
||||
carousel: '',
|
||||
mediaType: 'image',
|
||||
projection: 'plane',
|
||||
src: '/media/first.png, /media/second.png',
|
||||
title: 'Demo Carousel'
|
||||
}
|
||||
}));
|
||||
|
||||
assert.equal(config.carousel, true);
|
||||
assert.equal(config.mediaType, 'image');
|
||||
assert.equal(config.projectionMode, 'plane');
|
||||
assert.equal(config.src, '/media/first.png');
|
||||
assert.deepEqual(config.srcs, ['/media/first.png', '/media/second.png']);
|
||||
});
|
||||
|
||||
test('readLauncherMediaConfig rejects missing source or unsupported values', () => {
|
||||
assert.equal(readLauncherMediaConfig(createLauncher()), null);
|
||||
assert.equal(readLauncherMediaConfig(createLauncher({ dataset: { projection: 'cube', src: '/media/demo.png' } })), null);
|
||||
assert.equal(readLauncherMediaConfig(createLauncher({ dataset: { headLock: 'bad', src: '/media/demo.png' } })), null);
|
||||
assert.equal(readLauncherMediaConfig(createLauncher({ dataset: { mediaType: 'audio', src: '/media/demo.png' } })), null);
|
||||
assert.equal(readLauncherMediaConfig(createLauncher({ dataset: { src: '/media/demo' } })), null);
|
||||
assert.equal(readLauncherMediaConfig(createLauncher({ dataset: { carousel: '', mediaType: 'video', src: '/media/demo.mp4, /media/other.mp4' } })), null);
|
||||
assert.equal(readLauncherMediaConfig(createLauncher({ dataset: { carousel: '', src: '/media/demo.png' } })), null);
|
||||
assert.equal(readLauncherMediaConfig(createLauncher({ dataset: { src: '/media/first.png, /media/second.png' } })), null);
|
||||
});
|
||||
@@ -2,9 +2,20 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createMediaAdapter,
|
||||
ImageCarouselMediaAdapter,
|
||||
ImageMediaAdapter,
|
||||
VideoMediaAdapter
|
||||
} from '../vr180player/media/media-adapter.js';
|
||||
|
||||
function createClassList() {
|
||||
return {
|
||||
values: [],
|
||||
add(...values) {
|
||||
this.values.push(...values);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createVideo({
|
||||
ended = false,
|
||||
paused = false,
|
||||
@@ -12,16 +23,15 @@ function createVideo({
|
||||
title = ''
|
||||
} = {}) {
|
||||
return {
|
||||
classList: {
|
||||
values: [],
|
||||
add(value) {
|
||||
this.values.push(value);
|
||||
}
|
||||
},
|
||||
HAVE_METADATA: 1,
|
||||
classList: createClassList(),
|
||||
ended,
|
||||
loadCount: 0,
|
||||
paused,
|
||||
readyState: 0,
|
||||
style: { display: '' },
|
||||
tagName: 'VIDEO',
|
||||
addEventListener() {},
|
||||
getAttribute(name) {
|
||||
return name === 'title' ? title : '';
|
||||
},
|
||||
@@ -38,13 +48,49 @@ function createVideo({
|
||||
};
|
||||
}
|
||||
|
||||
function createImage({
|
||||
alt = '',
|
||||
complete = true,
|
||||
naturalWidth = 1920,
|
||||
source = 'https://cdn.example.com/images/demo-image.png',
|
||||
title = ''
|
||||
} = {}) {
|
||||
return {
|
||||
alt,
|
||||
classList: createClassList(),
|
||||
complete,
|
||||
currentSrc: source,
|
||||
listeners: {},
|
||||
naturalWidth,
|
||||
src: source,
|
||||
style: { display: '' },
|
||||
tagName: 'IMG',
|
||||
addEventListener(type, listener) {
|
||||
this.listeners[type] ??= [];
|
||||
this.listeners[type].push(listener);
|
||||
},
|
||||
dispatch(type) {
|
||||
for (const listener of this.listeners[type] ?? []) {
|
||||
listener({ currentTarget: this });
|
||||
}
|
||||
},
|
||||
getAttribute(name) {
|
||||
if (name === 'title') return title;
|
||||
if (name === 'alt') return alt;
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('VideoMediaAdapter exposes video capabilities and lifecycle helpers', () => {
|
||||
const video = createVideo({ title: 'Demo Title' });
|
||||
const adapter = new VideoMediaAdapter(video);
|
||||
|
||||
assert.deepEqual(adapter.capabilities, {
|
||||
audio: true,
|
||||
carousel: false,
|
||||
dynamicTexture: true,
|
||||
navigation: true,
|
||||
playback: true,
|
||||
timeline: true
|
||||
});
|
||||
@@ -77,11 +123,129 @@ test('VideoMediaAdapter falls back to source filename and tracks texture update
|
||||
assert.equal(adapter.shouldUpdateTexture(), false);
|
||||
});
|
||||
|
||||
test('ImageMediaAdapter exposes static image capabilities and lifecycle helpers', async () => {
|
||||
const image = createImage({ alt: 'Alt Title' });
|
||||
const adapter = new ImageMediaAdapter(image);
|
||||
let readyCount = 0;
|
||||
|
||||
adapter.bindLoadState({
|
||||
onError: () => {},
|
||||
onReady: () => {
|
||||
readyCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(adapter.capabilities, {
|
||||
audio: false,
|
||||
carousel: false,
|
||||
dynamicTexture: false,
|
||||
navigation: false,
|
||||
playback: false,
|
||||
timeline: false
|
||||
});
|
||||
assert.equal(adapter.element, image);
|
||||
assert.equal(adapter.textureSource, image);
|
||||
assert.equal(adapter.getTitle(), 'Alt Title');
|
||||
assert.equal(adapter.shouldUpdateTexture(), false);
|
||||
assert.equal(readyCount, 1);
|
||||
|
||||
adapter.hideElement();
|
||||
assert.equal(image.style.display, 'none');
|
||||
|
||||
adapter.showElement();
|
||||
assert.equal(image.style.display, '');
|
||||
});
|
||||
|
||||
test('ImageMediaAdapter falls back to source filename', () => {
|
||||
const image = createImage({ alt: '', source: 'https://cdn.example.com/media/static-sbs-demo.png' });
|
||||
const adapter = new ImageMediaAdapter(image);
|
||||
|
||||
assert.equal(adapter.getTitle(), 'static sbs demo');
|
||||
});
|
||||
|
||||
test('ImageCarouselMediaAdapter exposes carousel image navigation', () => {
|
||||
const firstImage = createImage({ title: 'First image', source: 'https://cdn.example.com/media/first.png' });
|
||||
const secondImage = createImage({ title: 'Second image', source: 'https://cdn.example.com/media/second.png' });
|
||||
const adapter = new ImageCarouselMediaAdapter([firstImage, secondImage]);
|
||||
|
||||
assert.deepEqual(adapter.capabilities, {
|
||||
audio: false,
|
||||
carousel: true,
|
||||
dynamicTexture: false,
|
||||
navigation: true,
|
||||
playback: false,
|
||||
timeline: false
|
||||
});
|
||||
assert.equal(adapter.element, firstImage);
|
||||
assert.equal(adapter.textureSource, firstImage);
|
||||
assert.equal(adapter.getTitle(), 'First image');
|
||||
assert.equal(firstImage.style.display, '');
|
||||
assert.equal(secondImage.style.display, 'none');
|
||||
assert.deepEqual(firstImage.classList.values, ['vrwp-media', 'vrwp-image', 'vrwp-carousel-image']);
|
||||
assert.deepEqual(secondImage.classList.values, ['vrwp-media', 'vrwp-image', 'vrwp-carousel-image']);
|
||||
|
||||
assert.equal(adapter.next(), true);
|
||||
assert.equal(adapter.element, secondImage);
|
||||
assert.equal(adapter.textureSource, secondImage);
|
||||
assert.equal(adapter.getTitle(), 'Second image');
|
||||
assert.equal(firstImage.style.display, 'none');
|
||||
assert.equal(secondImage.style.display, '');
|
||||
|
||||
assert.equal(adapter.next(), true);
|
||||
assert.equal(adapter.element, firstImage);
|
||||
|
||||
adapter.hideElement();
|
||||
assert.equal(firstImage.style.display, 'none');
|
||||
assert.equal(secondImage.style.display, 'none');
|
||||
|
||||
adapter.previous();
|
||||
adapter.showElement();
|
||||
assert.equal(adapter.element, secondImage);
|
||||
assert.equal(firstImage.style.display, 'none');
|
||||
assert.equal(secondImage.style.display, '');
|
||||
|
||||
adapter.load();
|
||||
assert.equal(firstImage.loading, 'eager');
|
||||
assert.equal(secondImage.loading, 'eager');
|
||||
});
|
||||
|
||||
test('ImageCarouselMediaAdapter waits for all images before reporting ready', async () => {
|
||||
const firstImage = createImage({ complete: false, naturalWidth: 0 });
|
||||
const secondImage = createImage({ complete: false, naturalWidth: 0 });
|
||||
const adapter = new ImageCarouselMediaAdapter([firstImage, secondImage]);
|
||||
let readyCount = 0;
|
||||
|
||||
adapter.bindLoadState({
|
||||
onError: () => {},
|
||||
onReady: () => {
|
||||
readyCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(readyCount, 0);
|
||||
|
||||
firstImage.complete = true;
|
||||
firstImage.naturalWidth = 1920;
|
||||
firstImage.dispatch('load');
|
||||
assert.equal(readyCount, 0);
|
||||
|
||||
secondImage.complete = true;
|
||||
secondImage.naturalWidth = 1920;
|
||||
secondImage.dispatch('load');
|
||||
assert.equal(readyCount, 1);
|
||||
|
||||
firstImage.dispatch('load');
|
||||
assert.equal(readyCount, 1);
|
||||
});
|
||||
|
||||
test('createMediaAdapter finds and marks the supported video element', () => {
|
||||
const video = createVideo();
|
||||
const playerContainer = {
|
||||
querySelector(selector) {
|
||||
return selector === 'video' ? video : null;
|
||||
querySelectorAll(selector) {
|
||||
return selector === 'video,img' ? [video] : [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,5 +253,50 @@ test('createMediaAdapter finds and marks the supported video element', () => {
|
||||
|
||||
assert.ok(adapter instanceof VideoMediaAdapter);
|
||||
assert.equal(adapter.element, video);
|
||||
assert.deepEqual(video.classList.values, ['vrwp-video']);
|
||||
assert.deepEqual(video.classList.values, ['vrwp-media', 'vrwp-video']);
|
||||
});
|
||||
|
||||
test('createMediaAdapter finds and marks the supported image element', () => {
|
||||
const image = createImage();
|
||||
const playerContainer = {
|
||||
querySelectorAll(selector) {
|
||||
return selector === 'video,img' ? [image] : [];
|
||||
}
|
||||
};
|
||||
|
||||
const adapter = createMediaAdapter(playerContainer);
|
||||
|
||||
assert.ok(adapter instanceof ImageMediaAdapter);
|
||||
assert.equal(adapter.element, image);
|
||||
assert.deepEqual(image.classList.values, ['vrwp-media', 'vrwp-image']);
|
||||
});
|
||||
|
||||
test('createMediaAdapter creates an image carousel when requested', () => {
|
||||
const firstImage = createImage({ title: 'First image' });
|
||||
const secondImage = createImage({ title: 'Second image' });
|
||||
const playerContainer = {
|
||||
dataset: { carousel: '' },
|
||||
querySelectorAll(selector) {
|
||||
return selector === 'video,img' ? [firstImage, secondImage] : [];
|
||||
}
|
||||
};
|
||||
|
||||
const adapter = createMediaAdapter(playerContainer);
|
||||
|
||||
assert.ok(adapter instanceof ImageCarouselMediaAdapter);
|
||||
assert.equal(adapter.element, firstImage);
|
||||
assert.equal(adapter.next(), true);
|
||||
assert.equal(adapter.element, secondImage);
|
||||
});
|
||||
|
||||
test('createMediaAdapter refuses missing or ambiguous media elements', () => {
|
||||
const video = createVideo();
|
||||
const image = createImage();
|
||||
const secondImage = createImage();
|
||||
|
||||
assert.equal(createMediaAdapter({ querySelectorAll: () => [] }), null);
|
||||
assert.equal(createMediaAdapter({ querySelectorAll: () => [video, image] }), null);
|
||||
assert.equal(createMediaAdapter({ querySelectorAll: () => [image, secondImage] }), null);
|
||||
assert.equal(createMediaAdapter({ dataset: { carousel: '' }, querySelectorAll: () => [image] }), null);
|
||||
assert.equal(createMediaAdapter({ dataset: { carousel: '' }, querySelectorAll: () => [video, image] }), null);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ function createVideo(overrides = {}) {
|
||||
currentTime: 20,
|
||||
duration: 120,
|
||||
ended: false,
|
||||
loop: false,
|
||||
loadCount: 0,
|
||||
muted: false,
|
||||
pauseCount: 0,
|
||||
@@ -107,6 +108,63 @@ test('MediaController toggles mute and native controls', () => {
|
||||
assert.equal(video.controls, true);
|
||||
});
|
||||
|
||||
test('MediaController toggles loop playback state', () => {
|
||||
const { controller, video } = createController();
|
||||
|
||||
assert.equal(controller.isLooping(), false);
|
||||
assert.equal(controller.toggleLoop(), true);
|
||||
assert.equal(video.loop, true);
|
||||
assert.equal(controller.isLooping(), true);
|
||||
assert.equal(controller.toggleLoop(), false);
|
||||
assert.equal(video.loop, false);
|
||||
});
|
||||
|
||||
test('MediaController restarts looping video just before the ended boundary', () => {
|
||||
const { controller, video } = createController({
|
||||
video: createVideo({
|
||||
currentTime: 119.9,
|
||||
duration: 120,
|
||||
loop: true,
|
||||
paused: false
|
||||
})
|
||||
});
|
||||
|
||||
controller.handleTimeUpdate();
|
||||
|
||||
assert.equal(video.currentTime, 0);
|
||||
assert.equal(video.playCount, 1);
|
||||
});
|
||||
|
||||
test('MediaController leaves non-looping or paused video alone near the ended boundary', () => {
|
||||
const { controller: nonLoopingController, video: nonLoopingVideo } = createController({
|
||||
video: createVideo({
|
||||
currentTime: 119.9,
|
||||
duration: 120,
|
||||
loop: false,
|
||||
paused: false
|
||||
})
|
||||
});
|
||||
|
||||
nonLoopingController.handleTimeUpdate();
|
||||
|
||||
assert.equal(nonLoopingVideo.currentTime, 119.9);
|
||||
assert.equal(nonLoopingVideo.playCount, 0);
|
||||
|
||||
const { controller: pausedController, video: pausedVideo } = createController({
|
||||
video: createVideo({
|
||||
currentTime: 119.9,
|
||||
duration: 120,
|
||||
loop: true,
|
||||
paused: true
|
||||
})
|
||||
});
|
||||
|
||||
pausedController.handleTimeUpdate();
|
||||
|
||||
assert.equal(pausedVideo.currentTime, 119.9);
|
||||
assert.equal(pausedVideo.playCount, 0);
|
||||
});
|
||||
|
||||
test('MediaController resets video and play button to poster state', () => {
|
||||
const playButton = { classList: createClassList(), disabled: true };
|
||||
playButton.classList.add('hidden');
|
||||
@@ -122,7 +180,7 @@ test('MediaController resets video and play button to poster state', () => {
|
||||
assert.equal(playButton.disabled, false);
|
||||
});
|
||||
|
||||
test('MediaController restarts ended video before playing in 2D mode', async () => {
|
||||
test('MediaController restarts ended video before playing again', async () => {
|
||||
let resumed = false;
|
||||
const { controller, video } = createController({
|
||||
is2DModeActive: () => true,
|
||||
@@ -138,6 +196,15 @@ test('MediaController restarts ended video before playing in 2D mode', async ()
|
||||
assert.equal(video.currentTime, 0);
|
||||
assert.equal(video.playCount, 1);
|
||||
assert.equal(resumed, true);
|
||||
|
||||
const vrVideo = createVideo({ currentTime: 120, ended: true, paused: true });
|
||||
const { controller: vrController } = createController({ video: vrVideo });
|
||||
|
||||
vrController.togglePlayPause();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(vrVideo.currentTime, 0);
|
||||
assert.equal(vrVideo.playCount, 1);
|
||||
});
|
||||
|
||||
test('MediaController pauses when toggling playback while already playing', () => {
|
||||
@@ -151,44 +218,37 @@ test('MediaController pauses when toggling playback while already playing', () =
|
||||
assert.equal(video.paused, true);
|
||||
});
|
||||
|
||||
test('MediaController dispatches ended behavior for VR, 2D, and idle modes', async () => {
|
||||
test('MediaController dispatches ended behavior for VR, 2D, and idle modes', () => {
|
||||
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'),
|
||||
onVrEnded: () => vrCalls.push('vr'),
|
||||
resetToOriginalState: () => vrCalls.push('reset')
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(vrCalls, ['exit']);
|
||||
assert.deepEqual(vrCalls, ['vr']);
|
||||
|
||||
const twoDCalls = [];
|
||||
controller.handleEnded({
|
||||
cleanupFailedVrExit: () => twoDCalls.push('cleanup'),
|
||||
exitVr: () => Promise.resolve(),
|
||||
isIn2DMode: () => true,
|
||||
isInVr: () => false,
|
||||
on2DEnded: () => twoDCalls.push('2d'),
|
||||
onVrEnded: () => twoDCalls.push('vr'),
|
||||
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'),
|
||||
onVrEnded: () => idleCalls.push('vr'),
|
||||
resetToOriginalState: () => idleCalls.push('reset')
|
||||
});
|
||||
assert.deepEqual(idleCalls, ['reset']);
|
||||
|
||||
@@ -2,10 +2,13 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
applyHeadPositionLock,
|
||||
applySbsTextureWindow,
|
||||
hideContentMeshes,
|
||||
isLeftEyeCamera,
|
||||
positionPlaneForPresentation,
|
||||
resetHeadPositionLockedContent,
|
||||
shouldLockContentToHeadPosition,
|
||||
showActiveContentMesh
|
||||
} from '../vr180player/rendering/projection.js';
|
||||
|
||||
@@ -30,10 +33,21 @@ function createRenderer({ isPresenting = false, xrCamera = null } = {}) {
|
||||
function createCamera(x, projectionOffset = 0) {
|
||||
return {
|
||||
matrixWorldInverse: { elements: new Array(16).fill(0).with(12, x) },
|
||||
matrixWorld: { elements: new Array(16).fill(0).with(12, x).with(13, 1.7).with(14, 0.25) },
|
||||
projectionMatrix: { elements: new Array(16).fill(0).with(8, projectionOffset) }
|
||||
};
|
||||
}
|
||||
|
||||
function createPositionedMesh() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
position: {
|
||||
set: (...args) => calls.push(args)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('applySbsTextureWindow uses left eye only in non-XR 2D fallback', () => {
|
||||
const material = createMaterial();
|
||||
|
||||
@@ -104,3 +118,44 @@ test('positionPlaneForPresentation uses the fallback camera depth in 2D plane mo
|
||||
assert.deepEqual(calls[0], [0, 1.6, -1.0999999999999999]);
|
||||
assert.deepEqual(calls[1], [0, 1.6, -3]);
|
||||
});
|
||||
|
||||
test('shouldLockContentToHeadPosition defaults to VR180 only in auto mode', () => {
|
||||
assert.equal(shouldLockContentToHeadPosition('auto', 'vr180'), true);
|
||||
assert.equal(shouldLockContentToHeadPosition('auto', 'plane'), false);
|
||||
assert.equal(shouldLockContentToHeadPosition('position', 'plane'), true);
|
||||
assert.equal(shouldLockContentToHeadPosition('none', 'vr180'), false);
|
||||
});
|
||||
|
||||
test('applyHeadPositionLock centers VR180 content on the XR camera position', () => {
|
||||
const mesh = createPositionedMesh();
|
||||
|
||||
applyHeadPositionLock(mesh, createCamera(0.4), 'vr180', true, 3);
|
||||
|
||||
assert.deepEqual(mesh.calls[0], [0.4, 1.7, 0.25]);
|
||||
});
|
||||
|
||||
test('applyHeadPositionLock keeps opt-in plane content in front of the XR camera position', () => {
|
||||
const mesh = createPositionedMesh();
|
||||
|
||||
applyHeadPositionLock(mesh, createCamera(-0.25), 'plane', true, 3);
|
||||
|
||||
assert.deepEqual(mesh.calls[0], [-0.25, 1.7, -2.75]);
|
||||
});
|
||||
|
||||
test('applyHeadPositionLock leaves content untouched when disabled', () => {
|
||||
const mesh = createPositionedMesh();
|
||||
|
||||
applyHeadPositionLock(mesh, createCamera(0.4), 'vr180', false, 3);
|
||||
|
||||
assert.deepEqual(mesh.calls, []);
|
||||
});
|
||||
|
||||
test('resetHeadPositionLockedContent restores default mesh positions', () => {
|
||||
const vr180Mesh = createPositionedMesh();
|
||||
const planeMesh = createPositionedMesh();
|
||||
|
||||
resetHeadPositionLockedContent(vr180Mesh, planeMesh, 3);
|
||||
|
||||
assert.deepEqual(vr180Mesh.calls[0], [0, 0, 0]);
|
||||
assert.deepEqual(planeMesh.calls[0], [0, 1.6, -3]);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,24 @@ test('MediaTextureManager assigns and clears material maps', () => {
|
||||
assert.equal(manager.current, null);
|
||||
});
|
||||
|
||||
test('MediaTextureManager can switch sources before creating the next texture', () => {
|
||||
const firstSource = { name: 'first' };
|
||||
const secondSource = { name: 'second' };
|
||||
const createdFrom = [];
|
||||
const manager = new MediaTextureManager(firstSource, (source) => {
|
||||
createdFrom.push(source);
|
||||
return createTexture(source.name);
|
||||
}, () => true);
|
||||
|
||||
const firstTexture = manager.create();
|
||||
manager.setSource(secondSource);
|
||||
const secondTexture = manager.create();
|
||||
|
||||
assert.equal(firstTexture.disposed, true);
|
||||
assert.equal(secondTexture.name, 'second');
|
||||
assert.deepEqual(createdFrom, [firstSource, secondSource]);
|
||||
});
|
||||
|
||||
test('MediaTextureManager only marks textures dirty when the update predicate allows it', () => {
|
||||
const video = createVideo();
|
||||
const manager = new MediaTextureManager(
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vrwp-video,
|
||||
.vrwp [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vrwp-media,
|
||||
.vrwp canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.vrwp-play-button {
|
||||
@@ -45,6 +51,71 @@
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
|
||||
.vrwp-launcher-host {
|
||||
position: fixed;
|
||||
left: -1px;
|
||||
top: -1px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vrwp-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483647;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
background: rgba(8, 8, 8, 0.82);
|
||||
}
|
||||
|
||||
.vrwp-modal[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vrwp-modal-dialog {
|
||||
position: relative;
|
||||
width: min(1120px, 100%);
|
||||
max-height: calc(100vh - 36px);
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: #111;
|
||||
box-shadow: 0 18px 70px rgba(0, 0, 0, 0.42);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.vrwp-modal-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vrwp-modal .vrwp {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vrwp-modal-close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vrwp-modal-close:hover {
|
||||
background: rgba(0, 0, 0, 0.76);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.vrwp-play-button {
|
||||
width: 60px;
|
||||
@@ -127,8 +198,9 @@
|
||||
|
||||
.vrwp-controls {
|
||||
display: grid;
|
||||
grid-template-areas: "full lflex nav rflex mute";
|
||||
grid-template-columns: 44px 1fr 156px 1fr 44px;
|
||||
grid-template-areas: "full lflex nav rflex loop mute";
|
||||
grid-template-columns: 44px 1fr 156px 1fr 44px 44px;
|
||||
column-gap: 8px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
@@ -155,6 +227,7 @@
|
||||
}
|
||||
|
||||
.vrwp-fullscreen,
|
||||
.vrwp-loop,
|
||||
.vrwp-mute,
|
||||
.vrwp-back,
|
||||
.vrwp-play-toggle,
|
||||
@@ -171,6 +244,14 @@
|
||||
grid-area: mute;
|
||||
}
|
||||
|
||||
.vrwp-loop {
|
||||
grid-area: loop;
|
||||
}
|
||||
|
||||
.vrwp-loop.active {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.vrwp-nav {
|
||||
grid-area: nav;
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user