forked from EXT/VR180-Web-Player
Compare commits
15 Commits
a470d4bdc7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddbcebf80a | ||
|
|
db81ea3721 | ||
|
|
fbfdc1c575 | ||
|
|
a4bbd71b31 | ||
|
|
4c8eed0bfe | ||
|
|
469dc81491 | ||
|
|
c86490542d | ||
|
|
731ee4e647 | ||
|
|
69511e4549 | ||
|
|
229c25947a | ||
|
|
cdaed5c712 | ||
|
|
b674df1555 | ||
|
|
1d4b3ce307 | ||
|
|
776c7c0629 | ||
|
|
fbdb733f13 |
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:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,7 +1,8 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
.env.r2
|
||||||
|
dist/
|
||||||
|
|
||||||
# Generated by `npm run build`.
|
# Generated by `npm run build`.
|
||||||
vr180player/*.css
|
vr180player/*.css
|
||||||
vr180player/*.js
|
vr180player/*.js
|
||||||
vr180player/**/*.js
|
vr180player/**/*.js
|
||||||
/media
|
|
||||||
|
|||||||
143
README.md
143
README.md
@@ -9,46 +9,75 @@ The player supports two projection modes:
|
|||||||
## How to use it
|
## 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.
|
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
|
Current F40 Pages CDN entrypoint:
|
||||||
<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:
|
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<div data-vr-web-player data-projection="plane">
|
<script type="module" src="https://pages.f-40.com/VR-Web-Player/vr180player/vr180-player.js"></script>
|
||||||
<video poster="poster.jpg" title="Demo Video" crossorigin="anonymous" playsinline preload="metadata">
|
|
||||||
<source src="sbs-video.mp4" type="video/mp4">
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `data-head-lock="auto|position|none"` to control positional comfort in immersive mode. It defaults to `auto`, which position-locks `vr180` media to the headset to avoid false 6DoF parallax, while leaving `plane` media fixed like a screen. Use `position` to force locking for either projection, or `none` to keep all media world-fixed.
|
|
||||||
|
|
||||||
Use an `img` element for a static SBS image:
|
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<div data-vr-web-player data-projection="plane">
|
<button
|
||||||
<img src="sbs-image.png" alt="Demo image" title="Demo Image" crossorigin="anonymous">
|
type="button"
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
|
<script type="module" src="https://pages.f-40.com/VR-Web-Player/vr180player/vr180-player.js"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
Add `data-carousel` to an image player when you want previous/next controls for multiple SBS still images in one immersive session:
|
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
|
```html
|
||||||
<div data-vr-web-player data-projection="vr180" data-carousel>
|
<button
|
||||||
<img src="first-sbs-image.png" alt="First image" title="First Image" crossorigin="anonymous">
|
type="button"
|
||||||
<img src="second-sbs-image.png" alt="Second image" title="Second Image" crossorigin="anonymous">
|
data-vr-web-launcher
|
||||||
</div>
|
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>
|
||||||
```
|
```
|
||||||
|
|
||||||
Only one player is supported per page in this version. The player logs a clear console message and does not initialize if no `[data-vr-web-player]` container is found, if multiple containers are found, if the container does not contain exactly one supported video/image element, if an image carousel does not contain at least two images and no videos, or if `data-projection` is not `vr180` or `plane`.
|
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
|
## Media format
|
||||||
This version supports side-by-side media only:
|
This version supports side-by-side media only:
|
||||||
@@ -59,7 +88,7 @@ This version supports side-by-side media only:
|
|||||||
It does not support over-under, MV-HEVC, APMP, or `.aivu`.
|
It does not support over-under, MV-HEVC, APMP, or `.aivu`.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
When the page loads, the media is embedded normally with an entry button over it. When the user clicks the button, the player checks for `navigator.xr` and `immersive-vr` support.
|
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 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, `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.
|
- In WebXR, `plane` maps the left and right halves onto the matching eyes of a floating 16:9 plane.
|
||||||
@@ -67,11 +96,13 @@ When the page loads, the media is embedded normally with an entry button over it
|
|||||||
- Video controls include a loop toggle for indefinite replay.
|
- Video controls include a loop toggle for indefinite replay.
|
||||||
- Static images show only applicable controls; playback, seek, and mute controls are video-only.
|
- 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.
|
- Image carousels replace skip buttons with previous/next image controls, so you can change stills without leaving the immersive session.
|
||||||
- Controller pointers and lightweight hand/controller overlays appear after controller interaction, then fade away after a short idle period so they do not distract from viewing.
|
- 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.
|
- Control icons are embedded from [Lucide](https://lucide.dev/) SVG definitions, so no PNG icon assets are required.
|
||||||
|
|
||||||
## Demo
|
## Demo
|
||||||
Run `npm run build`, then open `test-pages/index.html` through a local web server. The test hub links to focused pages for flat 3D image, VR180 3D image, image carousels, flat 3D video, and VR180 3D video. The root `index.html` redirects there for convenience.
|
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:
|
For local experimentation, run:
|
||||||
|
|
||||||
@@ -83,6 +114,43 @@ This builds the TypeScript player once, then serves `index.html` with Vite at a
|
|||||||
|
|
||||||
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.
|
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
|
## 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.
|
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.
|
||||||
|
|
||||||
@@ -90,6 +158,17 @@ The player source is TypeScript in `src/vr180player/`. Generated JavaScript file
|
|||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
npm run build
|
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.
|
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.
|
||||||
|
|||||||
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",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run build && vite --host 0.0.0.0",
|
"dev": "npm run build && vite --host 0.0.0.0",
|
||||||
"build": "tsc && node scripts/copy-styles.mjs",
|
"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",
|
"check": "tsc --noEmit",
|
||||||
"test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.test.mjs tests/media-adapter.test.mjs tests/hand-aim.test.mjs tests/input-mode.test.mjs",
|
"deploy:r2": "npm run build && npm run upload:r2",
|
||||||
"preview": "npm run build && vite preview --host 127.0.0.1"
|
"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": {
|
"devDependencies": {
|
||||||
"typescript": "^5.8.3",
|
"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 });
|
||||||
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';
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@ import {
|
|||||||
VALID_HEAD_LOCKS,
|
VALID_HEAD_LOCKS,
|
||||||
VALID_PROJECTIONS
|
VALID_PROJECTIONS
|
||||||
} from './config.js';
|
} 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 { createMediaAdapter, type SupportedMediaAdapter } from './media/media-adapter.js';
|
||||||
|
import { applyKnownImmersiveVrSupport } from './xr/xr-support.js';
|
||||||
|
|
||||||
export type BootstrapContext = {
|
export type BootstrapContext = {
|
||||||
headLockMode: HeadLockMode;
|
headLockMode: HeadLockMode;
|
||||||
@@ -18,71 +19,61 @@ export type BootstrapContext = {
|
|||||||
projectionMode: ProjectionMode;
|
projectionMode: ProjectionMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function bootstrapPlayer(playerBase: string, onReady: (context: BootstrapContext) => void): void {
|
type CreatePlayerContextOptions = {
|
||||||
injectPlayerStyles(playerBase);
|
immersiveVrSupported?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
onDocumentReady(() => {
|
export function createPlayerContext(playerContainer: HTMLElement, options: CreatePlayerContextOptions = {}): BootstrapContext | null {
|
||||||
const containers = document.querySelectorAll<HTMLElement>(PLAYER_SELECTOR);
|
playerContainer.classList.add('vrwp');
|
||||||
|
|
||||||
if (containers.length === 0) {
|
const configuredProjection = (playerContainer.dataset.projection || DEFAULT_PROJECTION).trim().toLowerCase();
|
||||||
console.error(`VR_WEB_PLAYER_DOM: Expected exactly one ${PLAYER_SELECTOR} container, found none.`);
|
if (!VALID_PROJECTIONS.has(configuredProjection as ProjectionMode)) {
|
||||||
return;
|
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 configuredHeadLock = (playerContainer.dataset.headLock || DEFAULT_HEAD_LOCK).trim().toLowerCase();
|
|
||||||
if (!VALID_HEAD_LOCKS.has(configuredHeadLock as HeadLockMode)) {
|
|
||||||
console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-head-lock="${configuredHeadLock}". Use "auto", "position", or "none".`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaAdapter = createMediaAdapter(playerContainer);
|
|
||||||
if (!mediaAdapter) {
|
|
||||||
console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain exactly one video/img, or multiple img elements with data-carousel.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const playButton = createPlayButton();
|
|
||||||
playerContainer.appendChild(playButton);
|
|
||||||
playerContainer.appendChild(create2DControlPanel());
|
|
||||||
playButton.disabled = true;
|
|
||||||
mediaAdapter.bindLoadState({
|
|
||||||
onError: (event) => {
|
|
||||||
console.error(`VR_WEB_PLAYER_MEDIA: Failed to load ${mediaAdapter.kind} media.`, event);
|
|
||||||
playButton.disabled = true;
|
|
||||||
},
|
|
||||||
onReady: () => {
|
|
||||||
playButton.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
mediaAdapter.load();
|
|
||||||
|
|
||||||
completeXrSupportCheck(playButton, () => {
|
|
||||||
onReady({
|
|
||||||
headLockMode: configuredHeadLock as HeadLockMode,
|
|
||||||
mediaAdapter,
|
|
||||||
playButton,
|
|
||||||
playerContainer,
|
|
||||||
projectionMode: configuredProjection as ProjectionMode
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
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') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', callback, { once: true });
|
document.addEventListener('DOMContentLoaded', callback, { once: true });
|
||||||
return;
|
return;
|
||||||
@@ -90,36 +81,3 @@ function onDocumentReady(callback: () => void): void {
|
|||||||
|
|
||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeXrSupportCheck(playButton: HTMLButtonElement, onComplete: () => void): void {
|
|
||||||
if (!navigator.xr) {
|
|
||||||
if (!window.isSecureContext) {
|
|
||||||
console.warn('VR_WEB_PLAYER_XR: Immersive WebXR requires a secure context. Serve the page over HTTPS, a trusted tunnel, or a deployed CDN URL to test in a headset.');
|
|
||||||
} else {
|
|
||||||
console.warn('VR_WEB_PLAYER_XR: navigator.xr is not available in this browser.');
|
|
||||||
}
|
|
||||||
markXrUnsupported(playButton);
|
|
||||||
onComplete();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigator.xr.isSessionSupported('immersive-vr').then((supported) => {
|
|
||||||
if (supported) {
|
|
||||||
playButton.dataset.xrSupported = 'true';
|
|
||||||
} else {
|
|
||||||
console.warn('VR_WEB_PLAYER_XR: immersive-vr is not supported by this browser/device.');
|
|
||||||
markXrUnsupported(playButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
onComplete();
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error('XR Support Check Error:', err);
|
|
||||||
markXrUnsupported(playButton);
|
|
||||||
onComplete();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function markXrUnsupported(playButton: HTMLButtonElement): void {
|
|
||||||
playButton.dataset.xrSupported = 'false';
|
|
||||||
playButton.disabled = false;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
export const PLAYER_SELECTOR = '[data-vr-web-player]';
|
export const PLAYER_SELECTOR = '[data-vr-web-player]';
|
||||||
|
export const LAUNCHER_SELECTOR = '[data-vr-web-launcher]';
|
||||||
|
|
||||||
export type ProjectionMode = 'vr180' | 'plane';
|
export type ProjectionMode = 'vr180' | 'plane';
|
||||||
export type HeadLockMode = 'auto' | 'position' | 'none';
|
export type HeadLockMode = 'auto' | 'position' | 'none';
|
||||||
|
export type LauncherMediaType = 'image' | 'video';
|
||||||
|
|
||||||
export const DEFAULT_PROJECTION: ProjectionMode = 'vr180';
|
export const DEFAULT_PROJECTION: ProjectionMode = 'vr180';
|
||||||
export const DEFAULT_HEAD_LOCK: HeadLockMode = 'auto';
|
export const DEFAULT_HEAD_LOCK: HeadLockMode = 'auto';
|
||||||
export const VALID_PROJECTIONS = new Set<ProjectionMode>(['vr180', 'plane']);
|
export const VALID_PROJECTIONS = new Set<ProjectionMode>(['vr180', 'plane']);
|
||||||
export const VALID_HEAD_LOCKS = new Set<HeadLockMode>(['auto', 'position', 'none']);
|
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_WIDTH = 3.2;
|
||||||
export const PLANE_HEIGHT = PLANE_WIDTH * (9 / 16);
|
export const PLANE_HEIGHT = PLANE_WIDTH * (9 / 16);
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ export type LucideIconName =
|
|||||||
| 'repeat'
|
| 'repeat'
|
||||||
| 'volume-2'
|
| 'volume-2'
|
||||||
| 'volume-x'
|
| 'volume-x'
|
||||||
| 'log-out';
|
| 'log-out'
|
||||||
|
| 'x';
|
||||||
|
|
||||||
type IconAttrs = Record<string, string>;
|
type IconAttrs = Record<string, string>;
|
||||||
type IconNode = readonly [tagName: string, attrs: IconAttrs];
|
type IconNode = readonly [tagName: string, attrs: IconAttrs];
|
||||||
@@ -74,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' }],
|
['path', { d: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4' }],
|
||||||
['polyline', { points: '16 17 21 12 16 7' }],
|
['polyline', { points: '16 17 21 12 16 7' }],
|
||||||
['line', { x1: '21', y1: '12', x2: '9', y2: '12' }]
|
['line', { x1: '21', y1: '12', x2: '9', y2: '12' }]
|
||||||
|
],
|
||||||
|
x: [
|
||||||
|
['path', { d: 'M18 6 6 18' }],
|
||||||
|
['path', { d: 'm6 6 12 12' }]
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,7 +165,15 @@ function drawIconNode(ctx: CanvasRenderingContext2D, tagName: string, attrs: Ico
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawPoints(ctx: CanvasRenderingContext2D, pointsAttr: string, closePath: boolean): void {
|
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;
|
if (points.length === 0) return;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { setLucideIcon } from './icons.js';
|
import { setLucideIcon } from './icons.js';
|
||||||
import { formatTime } from '../utils/time.js';
|
import { formatTime } from '../utils/time.js';
|
||||||
import type { MediaCapabilities } from '../media/media-adapter.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 = {
|
type TwoDControlPanelCallbacks = {
|
||||||
getIsLooping: () => boolean;
|
getIsLooping: () => boolean;
|
||||||
@@ -15,17 +16,17 @@ type TwoDControlPanelCallbacks = {
|
|||||||
type TwoDControlPanelOptions = {
|
type TwoDControlPanelOptions = {
|
||||||
callbacks: TwoDControlPanelCallbacks;
|
callbacks: TwoDControlPanelCallbacks;
|
||||||
fullscreenTarget: HTMLElement;
|
fullscreenTarget: HTMLElement;
|
||||||
|
getAutoHideDelayMs?: () => number;
|
||||||
getIsActive: () => boolean;
|
getIsActive: () => boolean;
|
||||||
mediaCapabilities: MediaCapabilities;
|
mediaCapabilities: MediaCapabilities;
|
||||||
playerContainer: HTMLElement;
|
playerContainer: HTMLElement;
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONTROL_PANEL_HIDE_DELAY = 3000;
|
|
||||||
|
|
||||||
export class TwoDControlPanel {
|
export class TwoDControlPanel {
|
||||||
private readonly callbacks: TwoDControlPanelCallbacks;
|
private readonly callbacks: TwoDControlPanelCallbacks;
|
||||||
private readonly fullscreenTarget: HTMLElement;
|
private readonly fullscreenTarget: HTMLElement;
|
||||||
|
private readonly getAutoHideDelayMs: () => number;
|
||||||
private readonly getIsActive: () => boolean;
|
private readonly getIsActive: () => boolean;
|
||||||
private readonly playerContainer: HTMLElement;
|
private readonly playerContainer: HTMLElement;
|
||||||
private controlPanel: HTMLElement | null;
|
private controlPanel: HTMLElement | null;
|
||||||
@@ -42,9 +43,10 @@ export class TwoDControlPanel {
|
|||||||
private navControls: HTMLElement | null;
|
private navControls: HTMLElement | null;
|
||||||
private progressControls: HTMLElement | null;
|
private progressControls: HTMLElement | null;
|
||||||
|
|
||||||
constructor({ callbacks, fullscreenTarget, getIsActive, mediaCapabilities, playerContainer, title }: TwoDControlPanelOptions) {
|
constructor({ callbacks, fullscreenTarget, getAutoHideDelayMs, getIsActive, mediaCapabilities, playerContainer, title }: TwoDControlPanelOptions) {
|
||||||
this.callbacks = callbacks;
|
this.callbacks = callbacks;
|
||||||
this.fullscreenTarget = fullscreenTarget;
|
this.fullscreenTarget = fullscreenTarget;
|
||||||
|
this.getAutoHideDelayMs = getAutoHideDelayMs ?? (() => DEFAULT_2D_CONTROL_AUTO_HIDE_DELAY_MS);
|
||||||
this.getIsActive = getIsActive;
|
this.getIsActive = getIsActive;
|
||||||
this.playerContainer = playerContainer;
|
this.playerContainer = playerContainer;
|
||||||
|
|
||||||
@@ -81,7 +83,7 @@ export class TwoDControlPanel {
|
|||||||
|
|
||||||
this.clearHideTimeout();
|
this.clearHideTimeout();
|
||||||
this.controlPanel.classList.add('visible');
|
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 {
|
showPersistent(): void {
|
||||||
@@ -141,6 +143,8 @@ export class TwoDControlPanel {
|
|||||||
this.playButton.classList.add('playing');
|
this.playButton.classList.add('playing');
|
||||||
setLucideIcon(this.playButton, 'pause');
|
setLucideIcon(this.playButton, 'pause');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.refreshAutoHideIfVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLoopButton(isLooping: boolean): void {
|
updateLoopButton(isLooping: boolean): void {
|
||||||
@@ -248,6 +252,14 @@ export class TwoDControlPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private refreshAutoHideIfVisible(): void {
|
||||||
|
if (!this.controlPanel?.classList.contains('visible')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.show();
|
||||||
|
}
|
||||||
|
|
||||||
private configureCarouselNavigation(): void {
|
private configureCarouselNavigation(): void {
|
||||||
if (this.backButton) {
|
if (this.backButton) {
|
||||||
this.backButton.setAttribute('aria-label', 'Previous image');
|
this.backButton.setAttribute('aria-label', 'Previous image');
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -14,12 +14,14 @@ type HandleMediaEndedOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SKIP_SECONDS = 15;
|
const DEFAULT_SKIP_SECONDS = 15;
|
||||||
|
const SEAMLESS_LOOP_LOOKAHEAD_SECONDS = 0.18;
|
||||||
|
|
||||||
export class MediaController {
|
export class MediaController {
|
||||||
private readonly is2DModeActive: () => boolean;
|
private readonly is2DModeActive: () => boolean;
|
||||||
private readonly on2DPlaybackResume: () => void;
|
private readonly on2DPlaybackResume: () => void;
|
||||||
private readonly playButton?: HTMLButtonElement;
|
private readonly playButton?: HTMLButtonElement;
|
||||||
private readonly video: HTMLVideoElement;
|
private readonly video: HTMLVideoElement;
|
||||||
|
private loopFrameCallbackId: number | undefined;
|
||||||
|
|
||||||
constructor({ is2DModeActive, on2DPlaybackResume, playButton, video }: MediaControllerOptions) {
|
constructor({ is2DModeActive, on2DPlaybackResume, playButton, video }: MediaControllerOptions) {
|
||||||
this.is2DModeActive = is2DModeActive;
|
this.is2DModeActive = is2DModeActive;
|
||||||
@@ -38,6 +40,10 @@ export class MediaController {
|
|||||||
this.video.currentTime = Math.min(this.video.duration, this.video.currentTime + seconds);
|
this.video.currentTime = Math.min(this.video.duration, this.video.currentTime + seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleTimeUpdate(): void {
|
||||||
|
this.loopBeforeEndedIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
handleEnded({
|
handleEnded({
|
||||||
isIn2DMode,
|
isIn2DMode,
|
||||||
isInVr,
|
isInVr,
|
||||||
@@ -72,13 +78,17 @@ export class MediaController {
|
|||||||
if (!this.video.paused) {
|
if (!this.video.paused) {
|
||||||
this.video.pause();
|
this.video.pause();
|
||||||
}
|
}
|
||||||
|
this.syncSeamlessLoopMonitor();
|
||||||
}
|
}
|
||||||
|
|
||||||
play(): Promise<void> {
|
play(): Promise<void> {
|
||||||
return this.video.play();
|
const playPromise = this.video.play();
|
||||||
|
playPromise.then(() => this.syncSeamlessLoopMonitor()).catch(() => {});
|
||||||
|
return playPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetToOriginalState(): void {
|
resetToOriginalState(): void {
|
||||||
|
this.stopSeamlessLoopMonitor();
|
||||||
this.video.pause();
|
this.video.pause();
|
||||||
this.video.currentTime = 0;
|
this.video.currentTime = 0;
|
||||||
this.video.controls = false;
|
this.video.controls = false;
|
||||||
@@ -106,6 +116,7 @@ export class MediaController {
|
|||||||
|
|
||||||
toggleLoop(): boolean {
|
toggleLoop(): boolean {
|
||||||
this.video.loop = !this.video.loop;
|
this.video.loop = !this.video.loop;
|
||||||
|
this.syncSeamlessLoopMonitor();
|
||||||
return this.video.loop;
|
return this.video.loop;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +132,7 @@ export class MediaController {
|
|||||||
const playPromise = this.video.play() as Promise<void> | undefined;
|
const playPromise = this.video.play() as Promise<void> | undefined;
|
||||||
if (playPromise !== undefined) {
|
if (playPromise !== undefined) {
|
||||||
playPromise.then(() => {
|
playPromise.then(() => {
|
||||||
|
this.syncSeamlessLoopMonitor();
|
||||||
if (this.is2DModeActive() && this.video.ended === false) {
|
if (this.is2DModeActive() && this.video.ended === false) {
|
||||||
this.on2DPlaybackResume();
|
this.on2DPlaybackResume();
|
||||||
}
|
}
|
||||||
@@ -133,5 +145,56 @@ export class MediaController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.video.pause();
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
type VideoEventCallbacks = {
|
type VideoEventCallbacks = {
|
||||||
onEnded: () => void;
|
onEnded: () => void;
|
||||||
|
onTimeUpdate?: () => void;
|
||||||
onPlaybackStateChange: () => void;
|
onPlaybackStateChange: () => void;
|
||||||
onTimelineChange: () => void;
|
onTimelineChange: () => void;
|
||||||
onVolumeChange: () => void;
|
onVolumeChange: () => void;
|
||||||
@@ -12,6 +13,7 @@ type BindVideoEventsOptions = VideoEventCallbacks & {
|
|||||||
|
|
||||||
export function bindVideoEvents({
|
export function bindVideoEvents({
|
||||||
onEnded,
|
onEnded,
|
||||||
|
onTimeUpdate,
|
||||||
onPlaybackStateChange,
|
onPlaybackStateChange,
|
||||||
onTimelineChange,
|
onTimelineChange,
|
||||||
onVolumeChange,
|
onVolumeChange,
|
||||||
@@ -35,6 +37,7 @@ export function bindVideoEvents({
|
|||||||
};
|
};
|
||||||
|
|
||||||
video.ontimeupdate = () => {
|
video.ontimeupdate = () => {
|
||||||
|
onTimeUpdate?.();
|
||||||
if (isFinite(video.duration)) {
|
if (isFinite(video.duration)) {
|
||||||
onTimelineChange();
|
onTimelineChange();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type TwoDModeCallbacks = {
|
|||||||
type TwoDModeOptions = {
|
type TwoDModeOptions = {
|
||||||
callbacks: TwoDModeCallbacks;
|
callbacks: TwoDModeCallbacks;
|
||||||
fullscreenTarget: HTMLElement;
|
fullscreenTarget: HTMLElement;
|
||||||
|
getControlsAutoHideDelayMs?: () => number;
|
||||||
mediaCapabilities: MediaCapabilities;
|
mediaCapabilities: MediaCapabilities;
|
||||||
getActiveContentMesh: () => any;
|
getActiveContentMesh: () => any;
|
||||||
getCamera: () => any;
|
getCamera: () => any;
|
||||||
@@ -92,6 +93,7 @@ export class TwoDMode {
|
|||||||
},
|
},
|
||||||
mediaCapabilities: this.mediaCapabilities,
|
mediaCapabilities: this.mediaCapabilities,
|
||||||
fullscreenTarget: this.fullscreenTarget,
|
fullscreenTarget: this.fullscreenTarget,
|
||||||
|
getAutoHideDelayMs: options.getControlsAutoHideDelayMs,
|
||||||
getIsActive: () => this.active,
|
getIsActive: () => this.active,
|
||||||
playerContainer: this.playerContainer,
|
playerContainer: this.playerContainer,
|
||||||
title: options.title
|
title: options.title
|
||||||
|
|||||||
@@ -51,6 +51,71 @@
|
|||||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.45));
|
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) {
|
@media (max-width: 600px) {
|
||||||
.vrwp-play-button {
|
.vrwp-play-button {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
|
LAUNCHER_SELECTOR,
|
||||||
PLANE_2D_DISTANCE,
|
PLANE_2D_DISTANCE,
|
||||||
PLANE_DISTANCE,
|
PLANE_DISTANCE,
|
||||||
|
PLAYER_SELECTOR,
|
||||||
type HeadLockMode,
|
type HeadLockMode,
|
||||||
type ProjectionMode
|
type ProjectionMode
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { bootstrapPlayer, type BootstrapContext } from './bootstrap.js';
|
import {
|
||||||
|
createPlayerContext,
|
||||||
|
onDocumentReady,
|
||||||
|
type BootstrapContext
|
||||||
|
} from './bootstrap.js';
|
||||||
|
import { injectPlayerStyles } from './dom/dom.js';
|
||||||
import { createContentScene } from './rendering/content-scene.js';
|
import { createContentScene } from './rendering/content-scene.js';
|
||||||
import {
|
import {
|
||||||
applyHeadPositionLock as applyHeadPositionLockCore,
|
applyHeadPositionLock as applyHeadPositionLockCore,
|
||||||
@@ -37,6 +44,12 @@ import {
|
|||||||
} from './rendering/renderer-lifecycle.js';
|
} from './rendering/renderer-lifecycle.js';
|
||||||
import { MediaTextureManager } from './rendering/texture-manager.js';
|
import { MediaTextureManager } from './rendering/texture-manager.js';
|
||||||
import type { SupportedMediaAdapter } from './media/media-adapter.js';
|
import type { SupportedMediaAdapter } from './media/media-adapter.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_2D_CONTROL_AUTO_HIDE_DELAY_MS,
|
||||||
|
PLAYING_VIDEO_2D_CONTROL_AUTO_HIDE_DELAY_MS,
|
||||||
|
getVideoAwareAutoHideDelayMs
|
||||||
|
} from './utils/control-panel-timing.js';
|
||||||
|
import { setupLauncherButtons } from './launcher/launcher-bootstrap.js';
|
||||||
|
|
||||||
export class PlayerSession {
|
export class PlayerSession {
|
||||||
private readonly headLockMode: HeadLockMode;
|
private readonly headLockMode: HeadLockMode;
|
||||||
@@ -46,7 +59,11 @@ export class PlayerSession {
|
|||||||
private readonly projectionMode: ProjectionMode;
|
private readonly projectionMode: ProjectionMode;
|
||||||
private readonly uiElements: any[] = [];
|
private readonly uiElements: any[] = [];
|
||||||
private readonly vrPanelVisibility = new VrPanelVisibility();
|
private readonly vrPanelVisibility = new VrPanelVisibility();
|
||||||
|
private readonly handleEnterButtonClick = () => {
|
||||||
|
void this.enterOrShowFallback();
|
||||||
|
};
|
||||||
private readonly handleVrSessionEnd = (event: any) => this.onVRSessionEnd(event);
|
private readonly handleVrSessionEnd = (event: any) => this.onVRSessionEnd(event);
|
||||||
|
private readonly handleWindowResize = () => this.onWindowResize();
|
||||||
private readonly renderXrFrame = (timestamp: number, frame: any) => this.renderXR(timestamp, frame);
|
private readonly renderXrFrame = (timestamp: number, frame: any) => this.renderXR(timestamp, frame);
|
||||||
|
|
||||||
private activeContentMesh: any;
|
private activeContentMesh: any;
|
||||||
@@ -134,6 +151,7 @@ export class PlayerSession {
|
|||||||
togglePlayPause: () => this.mediaController?.togglePlayPause()
|
togglePlayPause: () => this.mediaController?.togglePlayPause()
|
||||||
},
|
},
|
||||||
fullscreenTarget: this.playerContainer,
|
fullscreenTarget: this.playerContainer,
|
||||||
|
getControlsAutoHideDelayMs: () => this.get2DControlsAutoHideDelayMs(),
|
||||||
mediaCapabilities: this.mediaAdapter.capabilities,
|
mediaCapabilities: this.mediaAdapter.capabilities,
|
||||||
getActiveContentMesh: () => this.activeContentMesh,
|
getActiveContentMesh: () => this.activeContentMesh,
|
||||||
getCamera: () => this.camera2D,
|
getCamera: () => this.camera2D,
|
||||||
@@ -166,17 +184,23 @@ export class PlayerSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.playBtn.addEventListener('click', () => {
|
this.playBtn.addEventListener('click', this.handleEnterButtonClick);
|
||||||
void this.handleEnterVRButtonClick();
|
window.addEventListener('resize', this.handleWindowResize);
|
||||||
});
|
|
||||||
window.addEventListener('resize', () => this.onWindowResize());
|
|
||||||
|
|
||||||
if (this.video) {
|
if (this.video) {
|
||||||
bindVideoEvents({
|
bindVideoEvents({
|
||||||
onEnded: () => this.onVideoEnded(),
|
onEnded: () => this.onVideoEnded(),
|
||||||
onPlaybackStateChange: () => {
|
onPlaybackStateChange: () => {
|
||||||
|
const shouldRefreshVrPanelHide = this.vrPanelVisibility.isVisible;
|
||||||
|
this.mediaController?.syncSeamlessLoopMonitor();
|
||||||
this.updateVRPlayPauseButtonIcon();
|
this.updateVRPlayPauseButtonIcon();
|
||||||
this.update2DPlayPauseButton();
|
this.update2DPlayPauseButton();
|
||||||
|
if (shouldRefreshVrPanelHide) {
|
||||||
|
this.showPanel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTimeUpdate: () => {
|
||||||
|
this.mediaController?.handleTimeUpdate();
|
||||||
},
|
},
|
||||||
onTimelineChange: () => {
|
onTimelineChange: () => {
|
||||||
this.updateSeekBarAppearance();
|
this.updateSeekBarAppearance();
|
||||||
@@ -195,6 +219,50 @@ export class PlayerSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async enterOrShowFallback(): Promise<void> {
|
||||||
|
if (this.playBtn.dataset.xrSupported === 'true') {
|
||||||
|
await this.enterImmersive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
async enterImmersive(): Promise<boolean> {
|
||||||
|
if (!this.mediaAdapter) {
|
||||||
|
console.error('Media element not found for immersive launcher.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.playBtn.dataset.xrSupported !== 'true') {
|
||||||
|
this.showFallback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hideEnterButton();
|
||||||
|
return this.actualSessionToggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
showFallback(): void {
|
||||||
|
if (!this.mediaAdapter) {
|
||||||
|
console.error('Media element not found for fallback player.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hideEnterButton();
|
||||||
|
this.resetHeadPositionLock();
|
||||||
|
this.twoDMode?.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopFallback(): void {
|
||||||
|
if (this.twoDMode?.isActive) {
|
||||||
|
this.twoDMode.stop();
|
||||||
|
this.onWindowResize();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mediaController?.pauseIfPlaying();
|
||||||
|
}
|
||||||
|
|
||||||
private applySbsTextureWindow(renderingRenderer: any, activeCamera: any, material: any): void {
|
private applySbsTextureWindow(renderingRenderer: any, activeCamera: any, material: any): void {
|
||||||
applySbsTextureWindowCore(renderingRenderer, activeCamera, material, this.is2DModeActive());
|
applySbsTextureWindowCore(renderingRenderer, activeCamera, material, this.is2DModeActive());
|
||||||
}
|
}
|
||||||
@@ -330,7 +398,7 @@ export class PlayerSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private showPanel(): void {
|
private showPanel(): void {
|
||||||
this.vrPanelVisibility.show();
|
this.vrPanelVisibility.show(this.getVrPanelAutoHideDelayMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
private showPanelPersistent(): void {
|
private showPanelPersistent(): void {
|
||||||
@@ -345,6 +413,17 @@ export class PlayerSession {
|
|||||||
return this.vrPanelVisibility.isVisible ? (this.vrPanel?.interactables ?? []) : [];
|
return this.vrPanelVisibility.isVisible ? (this.vrPanel?.interactables ?? []) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getVrPanelAutoHideDelayMs(): number {
|
||||||
|
return getVideoAwareAutoHideDelayMs(this.video);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get2DControlsAutoHideDelayMs(): number {
|
||||||
|
return getVideoAwareAutoHideDelayMs(this.video, {
|
||||||
|
idleDelayMs: DEFAULT_2D_CONTROL_AUTO_HIDE_DELAY_MS,
|
||||||
|
playingDelayMs: PLAYING_VIDEO_2D_CONTROL_AUTO_HIDE_DELAY_MS
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private onWindowResize(): void {
|
private onWindowResize(): void {
|
||||||
if (!this.renderer) return;
|
if (!this.renderer) return;
|
||||||
|
|
||||||
@@ -462,27 +541,10 @@ export class PlayerSession {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleEnterVRButtonClick(): Promise<void> {
|
private async actualSessionToggle(): Promise<boolean> {
|
||||||
if (!this.mediaAdapter) {
|
|
||||||
console.error('Media element not found for VR button click.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.hideEnterButton();
|
|
||||||
|
|
||||||
if (this.playBtn.dataset.xrSupported === 'true') {
|
|
||||||
await this.actualSessionToggle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.resetHeadPositionLock();
|
|
||||||
this.twoDMode?.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async actualSessionToggle(): Promise<void> {
|
|
||||||
if (!this.renderer || !this.renderer.isWebGLRenderer) {
|
if (!this.renderer || !this.renderer.isWebGLRenderer) {
|
||||||
console.error('CRITICAL_ERROR: actualSessionToggle: renderer is NOT a WebGLRenderer or is null!', this.renderer);
|
console.error('CRITICAL_ERROR: actualSessionToggle: renderer is NOT a WebGLRenderer or is null!', this.renderer);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.xrSession) {
|
if (this.xrSession) {
|
||||||
@@ -496,13 +558,12 @@ export class PlayerSession {
|
|||||||
console.error('Error calling .end() on session:', err);
|
console.error('Error calling .end() on session:', err);
|
||||||
this.onVRSessionEnd({ session: sessionToClose });
|
this.onVRSessionEnd({ session: sessionToClose });
|
||||||
});
|
});
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await navigator.xr.requestSession('immersive-vr', {
|
const session = await navigator.xr.requestSession('immersive-vr', {
|
||||||
requiredFeatures: ['local-floor'],
|
requiredFeatures: ['local-floor']
|
||||||
optionalFeatures: ['hand-tracking']
|
|
||||||
});
|
});
|
||||||
if (!session) { throw new Error('requestSession returned no session.'); }
|
if (!session) { throw new Error('requestSession returned no session.'); }
|
||||||
|
|
||||||
@@ -544,6 +605,7 @@ export class PlayerSession {
|
|||||||
this.isXrLoopActive = true;
|
this.isXrLoopActive = true;
|
||||||
this.renderer.setAnimationLoop(this.renderXrFrame);
|
this.renderer.setAnimationLoop(this.renderXrFrame);
|
||||||
this.frameCounter = 0;
|
this.frameCounter = 0;
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const sessionStartError = 'XR_ERROR: Failed to start VR session: ' + (err.message || String(err));
|
const sessionStartError = 'XR_ERROR: Failed to start VR session: ' + (err.message || String(err));
|
||||||
console.error(sessionStartError, err);
|
console.error(sessionStartError, err);
|
||||||
@@ -567,6 +629,7 @@ export class PlayerSession {
|
|||||||
if (this.renderer && this.renderer.getAnimationLoop && this.renderer.getAnimationLoop()) {
|
if (this.renderer && this.renderer.getAnimationLoop && this.renderer.getAnimationLoop()) {
|
||||||
this.renderer.setAnimationLoop(null);
|
this.renderer.setAnimationLoop(null);
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,7 +742,31 @@ export class PlayerSession {
|
|||||||
const playerBase = new URL('.', import.meta.url).href;
|
const playerBase = new URL('.', import.meta.url).href;
|
||||||
let activeSession: PlayerSession | undefined;
|
let activeSession: PlayerSession | undefined;
|
||||||
|
|
||||||
bootstrapPlayer(playerBase, (context) => {
|
injectPlayerStyles(playerBase);
|
||||||
activeSession = new PlayerSession(context);
|
|
||||||
activeSession.init();
|
onDocumentReady(() => {
|
||||||
|
const initialized = setupLauncherButtons({
|
||||||
|
createSession: (playerContainer, immersiveVrSupported) => {
|
||||||
|
const context = createPlayerContext(playerContainer, { immersiveVrSupported });
|
||||||
|
if (!context) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSession = new PlayerSession(context);
|
||||||
|
activeSession.init();
|
||||||
|
return activeSession;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldInlineContainers = document.querySelectorAll(PLAYER_SELECTOR).length;
|
||||||
|
if (oldInlineContainers > 0) {
|
||||||
|
console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} is now internal and is no longer initialized from page markup. Use one or more ${LAUNCHER_SELECTOR} elements instead.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`VR_WEB_PLAYER_DOM: Expected at least one ${LAUNCHER_SELECTOR} element for the gallery launcher.`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
|
||||||
import {
|
|
||||||
computePalmAimRay,
|
|
||||||
type PalmAimRay,
|
|
||||||
type VectorLike
|
|
||||||
} from './hand-aim.js';
|
|
||||||
|
|
||||||
export type AimRay = {
|
|
||||||
direction: any;
|
|
||||||
origin: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEFAULT_RAY_DIRECTION = new THREE.Vector3(0, 0, -1);
|
|
||||||
|
|
||||||
export function getHandAimRay(hand: any): AimRay | null {
|
|
||||||
const joints = hand?.joints;
|
|
||||||
if (!joints) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const palmAimRay = computePalmAimRay({
|
|
||||||
handedness: getHandedness(hand),
|
|
||||||
indexMetacarpal: getJointWorldPosition(joints['index-finger-metacarpal']),
|
|
||||||
middleMetacarpal: getJointWorldPosition(joints['middle-finger-metacarpal']),
|
|
||||||
pinkyMetacarpal: getJointWorldPosition(joints['pinky-finger-metacarpal']),
|
|
||||||
ringMetacarpal: getJointWorldPosition(joints['ring-finger-metacarpal']),
|
|
||||||
wrist: getJointWorldPosition(joints.wrist)
|
|
||||||
});
|
|
||||||
if (!palmAimRay) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return toAimRay(palmAimRay);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toPalmAimRay(ray: AimRay): PalmAimRay {
|
|
||||||
return {
|
|
||||||
direction: fromThreeVector(ray.direction),
|
|
||||||
origin: fromThreeVector(ray.origin)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toAimRay(ray: PalmAimRay): AimRay {
|
|
||||||
return {
|
|
||||||
direction: toThreeVector(ray.direction),
|
|
||||||
origin: toThreeVector(ray.origin)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rememberHandedness(hand: any, event: any): void {
|
|
||||||
const handedness = event?.data?.handedness ||
|
|
||||||
event?.data?.inputSource?.handedness ||
|
|
||||||
hand?.inputState?.handedness;
|
|
||||||
|
|
||||||
if (handedness !== 'left' && handedness !== 'right') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
hand.userData = {
|
|
||||||
...hand.userData,
|
|
||||||
vrwpHandedness: handedness
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getHandedness(hand: any): string | undefined {
|
|
||||||
return hand?.userData?.vrwpHandedness ||
|
|
||||||
hand?.inputState?.handedness ||
|
|
||||||
hand?.userData?.inputSource?.handedness;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getJointWorldPosition(joint: any): VectorLike | null {
|
|
||||||
if (!joint?.getWorldPosition) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
joint.updateMatrixWorld?.(true);
|
|
||||||
return joint.getWorldPosition(new THREE.Vector3());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toThreeVector(vector: VectorLike): any {
|
|
||||||
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromThreeVector(vector: any): VectorLike {
|
|
||||||
return {
|
|
||||||
x: vector.x,
|
|
||||||
y: vector.y,
|
|
||||||
z: vector.z
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
export type VectorLike = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
z: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PalmAimInput = {
|
|
||||||
handedness?: string | null;
|
|
||||||
indexMetacarpal?: VectorLike | null;
|
|
||||||
middleMetacarpal?: VectorLike | null;
|
|
||||||
pinkyMetacarpal?: VectorLike | null;
|
|
||||||
ringMetacarpal?: VectorLike | null;
|
|
||||||
wrist?: VectorLike | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PalmAimRay = {
|
|
||||||
direction: VectorLike;
|
|
||||||
origin: VectorLike;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TimedPalmAimRay = PalmAimRay & {
|
|
||||||
timestamp: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PalmAimLatch = {
|
|
||||||
isSelecting: boolean;
|
|
||||||
selectedRay: TimedPalmAimRay | null;
|
|
||||||
stableRay: TimedPalmAimRay | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MIN_AXIS_LENGTH_SQ = 0.000001;
|
|
||||||
const PALM_AIM_FORWARD_TILT_DEGREES = 40;
|
|
||||||
const PALM_SURFACE_OFFSET_METERS = 0.035;
|
|
||||||
export const DEFAULT_PALM_AIM_LATCH_MAX_AGE_MS = 300;
|
|
||||||
|
|
||||||
export function computePalmAimRay(input: PalmAimInput): PalmAimRay | null {
|
|
||||||
const { indexMetacarpal, pinkyMetacarpal, wrist } = input;
|
|
||||||
if (!indexMetacarpal || !pinkyMetacarpal || !wrist) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const knuckleCenter = averageVectors([
|
|
||||||
indexMetacarpal,
|
|
||||||
input.middleMetacarpal,
|
|
||||||
input.ringMetacarpal,
|
|
||||||
pinkyMetacarpal
|
|
||||||
]);
|
|
||||||
if (!knuckleCenter) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fingerAxis = normalize(subtract(knuckleCenter, wrist));
|
|
||||||
const acrossPalmAxis = normalize(subtract(pinkyMetacarpal, indexMetacarpal));
|
|
||||||
if (!fingerAxis || !acrossPalmAxis) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const direction = normalize(getTiltedPalmDirection(
|
|
||||||
getPalmNormal(fingerAxis, acrossPalmAxis, input.handedness),
|
|
||||||
fingerAxis
|
|
||||||
));
|
|
||||||
if (!direction) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const palmCenter = lerp(wrist, knuckleCenter, 0.62);
|
|
||||||
const origin = add(palmCenter, scale(direction, PALM_SURFACE_OFFSET_METERS));
|
|
||||||
return { direction, origin };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPalmAimLatch(): PalmAimLatch {
|
|
||||||
return {
|
|
||||||
isSelecting: false,
|
|
||||||
selectedRay: null,
|
|
||||||
stableRay: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function recordStablePalmAimRay(
|
|
||||||
latch: PalmAimLatch | null | undefined,
|
|
||||||
ray: PalmAimRay,
|
|
||||||
timestamp: number
|
|
||||||
): void {
|
|
||||||
if (!latch) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (latch.isSelecting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
latch.stableRay = withTimestamp(ray, timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function beginPalmAimSelection(
|
|
||||||
latch: PalmAimLatch | null | undefined,
|
|
||||||
timestamp: number,
|
|
||||||
maxAgeMs = DEFAULT_PALM_AIM_LATCH_MAX_AGE_MS
|
|
||||||
): PalmAimRay | null {
|
|
||||||
if (!latch) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
latch.isSelecting = true;
|
|
||||||
const stableRay = getFreshTimedRay(latch.stableRay, timestamp, maxAgeMs);
|
|
||||||
latch.selectedRay = stableRay ? cloneTimedRay(stableRay) : null;
|
|
||||||
return latch.selectedRay ? clonePalmAimRay(latch.selectedRay) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function endPalmAimSelection(latch: PalmAimLatch | null | undefined): void {
|
|
||||||
if (!latch) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
latch.isSelecting = false;
|
|
||||||
latch.selectedRay = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPalmAimSelectionRay(
|
|
||||||
latch: PalmAimLatch | null | undefined,
|
|
||||||
timestamp: number,
|
|
||||||
maxAgeMs = DEFAULT_PALM_AIM_LATCH_MAX_AGE_MS
|
|
||||||
): PalmAimRay | null {
|
|
||||||
if (!latch) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (latch.isSelecting && latch.selectedRay) {
|
|
||||||
return clonePalmAimRay(latch.selectedRay);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stableRay = getFreshTimedRay(latch.stableRay, timestamp, maxAgeMs);
|
|
||||||
return stableRay ? clonePalmAimRay(stableRay) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPalmNormal(fingerAxis: VectorLike, acrossPalmAxis: VectorLike, handedness?: string | null): VectorLike {
|
|
||||||
if (handedness === 'left') {
|
|
||||||
return cross(acrossPalmAxis, fingerAxis);
|
|
||||||
}
|
|
||||||
|
|
||||||
return cross(fingerAxis, acrossPalmAxis);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTiltedPalmDirection(palmNormal: VectorLike, fingerAxis: VectorLike): VectorLike {
|
|
||||||
const tiltRadians = (PALM_AIM_FORWARD_TILT_DEGREES * Math.PI) / 180;
|
|
||||||
return add(
|
|
||||||
scale(palmNormal, Math.cos(tiltRadians)),
|
|
||||||
scale(fingerAxis, Math.sin(tiltRadians))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function withTimestamp(ray: PalmAimRay, timestamp: number): TimedPalmAimRay {
|
|
||||||
return {
|
|
||||||
...clonePalmAimRay(ray),
|
|
||||||
timestamp
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneTimedRay(ray: TimedPalmAimRay): TimedPalmAimRay {
|
|
||||||
return {
|
|
||||||
...clonePalmAimRay(ray),
|
|
||||||
timestamp: ray.timestamp
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function clonePalmAimRay(ray: PalmAimRay): PalmAimRay {
|
|
||||||
return {
|
|
||||||
direction: cloneVector(ray.direction),
|
|
||||||
origin: cloneVector(ray.origin)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneVector(vector: VectorLike): VectorLike {
|
|
||||||
return {
|
|
||||||
x: vector.x,
|
|
||||||
y: vector.y,
|
|
||||||
z: vector.z
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFreshTimedRay(
|
|
||||||
ray: TimedPalmAimRay | null,
|
|
||||||
timestamp: number,
|
|
||||||
maxAgeMs: number
|
|
||||||
): TimedPalmAimRay | null {
|
|
||||||
if (!ray) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ageMs = Math.max(0, timestamp - ray.timestamp);
|
|
||||||
return ageMs <= maxAgeMs ? ray : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function averageVectors(vectors: Array<VectorLike | null | undefined>): VectorLike | null {
|
|
||||||
const usableVectors = vectors.filter(Boolean) as VectorLike[];
|
|
||||||
if (usableVectors.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = usableVectors.reduce(
|
|
||||||
(sum, vector) => add(sum, vector),
|
|
||||||
{ x: 0, y: 0, z: 0 }
|
|
||||||
);
|
|
||||||
return scale(total, 1 / usableVectors.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(vector: VectorLike): VectorLike | null {
|
|
||||||
const lengthSq = vector.x * vector.x + vector.y * vector.y + vector.z * vector.z;
|
|
||||||
if (lengthSq < MIN_AXIS_LENGTH_SQ) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const length = Math.sqrt(lengthSq);
|
|
||||||
return scale(vector, 1 / length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function add(a: VectorLike, b: VectorLike): VectorLike {
|
|
||||||
return {
|
|
||||||
x: a.x + b.x,
|
|
||||||
y: a.y + b.y,
|
|
||||||
z: a.z + b.z
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function subtract(a: VectorLike, b: VectorLike): VectorLike {
|
|
||||||
return {
|
|
||||||
x: a.x - b.x,
|
|
||||||
y: a.y - b.y,
|
|
||||||
z: a.z - b.z
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function scale(vector: VectorLike, scalar: number): VectorLike {
|
|
||||||
return {
|
|
||||||
x: vector.x * scalar,
|
|
||||||
y: vector.y * scalar,
|
|
||||||
z: vector.z * scalar
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function lerp(a: VectorLike, b: VectorLike, amount: number): VectorLike {
|
|
||||||
return {
|
|
||||||
x: a.x + (b.x - a.x) * amount,
|
|
||||||
y: a.y + (b.y - a.y) * amount,
|
|
||||||
z: a.z + (b.z - a.z) * amount
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function cross(a: VectorLike, b: VectorLike): VectorLike {
|
|
||||||
return {
|
|
||||||
x: a.y * b.z - a.z * b.y,
|
|
||||||
y: a.z * b.x - a.x * b.z,
|
|
||||||
z: a.x * b.y - a.y * b.x
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export type PointerInputMode = 'controller' | 'hand';
|
export type PointerInputMode = 'controller';
|
||||||
|
|
||||||
export type PointerInputModeCarrier = {
|
export type PointerInputModeCarrier = {
|
||||||
controller?: {
|
controller?: {
|
||||||
@@ -31,22 +31,9 @@ export function getPointerInputMode(eventInputSource: any): PointerInputMode | n
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventInputSource.hand) {
|
|
||||||
return 'hand';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(eventInputSource.profiles) &&
|
|
||||||
eventInputSource.profiles.some((profile: string) => profile.toLowerCase().includes('hand'))) {
|
|
||||||
return 'hand';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventInputSource.gamepad || eventInputSource.targetRayMode === 'tracked-pointer') {
|
if (eventInputSource.gamepad || eventInputSource.targetRayMode === 'tracked-pointer') {
|
||||||
return 'controller';
|
return 'controller';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldUseHandPointer(inputSource: PointerInputModeCarrier | undefined): boolean {
|
|
||||||
return inputSource?.pointerInputMode === 'hand';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,36 +3,14 @@ import {
|
|||||||
getSeekProgressFromIntersection,
|
getSeekProgressFromIntersection,
|
||||||
type VrControlPanel
|
type VrControlPanel
|
||||||
} from './vr-control-panel.js';
|
} from './vr-control-panel.js';
|
||||||
import {
|
import { rememberPointerInputMode } from './input-mode.js';
|
||||||
beginPalmAimSelection,
|
|
||||||
createPalmAimLatch,
|
|
||||||
endPalmAimSelection,
|
|
||||||
getPalmAimSelectionRay,
|
|
||||||
recordStablePalmAimRay,
|
|
||||||
type PalmAimLatch
|
|
||||||
} from './hand-aim.js';
|
|
||||||
import {
|
|
||||||
DEFAULT_RAY_DIRECTION,
|
|
||||||
getHandAimRay,
|
|
||||||
rememberHandedness,
|
|
||||||
toAimRay,
|
|
||||||
toPalmAimRay,
|
|
||||||
type AimRay
|
|
||||||
} from './hand-aim-three.js';
|
|
||||||
import {
|
|
||||||
rememberPointerInputMode,
|
|
||||||
shouldUseHandPointer,
|
|
||||||
type PointerInputMode
|
|
||||||
} from './input-mode.js';
|
|
||||||
import {
|
import {
|
||||||
bindOverlayActivity,
|
bindOverlayActivity,
|
||||||
createControllerOverlay,
|
createControllerOverlay,
|
||||||
createHandOverlay,
|
|
||||||
createPointerOverlay,
|
createPointerOverlay,
|
||||||
createWorldPointerOverlay,
|
getPointerIntersectionLength,
|
||||||
POINTER_HIT_SURFACE_OFFSET,
|
|
||||||
POINTER_LENGTH,
|
POINTER_LENGTH,
|
||||||
POINTER_MIN_LENGTH,
|
resetInputPointerLengths,
|
||||||
setPointerOverlayLength,
|
setPointerOverlayLength,
|
||||||
VrOverlayVisibility
|
VrOverlayVisibility
|
||||||
} from './pointer-overlays.js';
|
} from './pointer-overlays.js';
|
||||||
@@ -51,28 +29,21 @@ type ActiveSeekDrag = {
|
|||||||
panel: VrControlPanel;
|
panel: VrControlPanel;
|
||||||
};
|
};
|
||||||
|
|
||||||
type HandPointerOverlay = {
|
type AimRay = {
|
||||||
fallbackPointerOverlay: any;
|
direction: any;
|
||||||
hand: any;
|
origin: any;
|
||||||
handAimLatch: PalmAimLatch;
|
|
||||||
inputSource: VrInputSource;
|
|
||||||
pointerOverlay: any;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type VrInputSource = {
|
type VrInputSource = {
|
||||||
controller: any;
|
controller: any;
|
||||||
controllerPointerOverlay: any;
|
controllerPointerOverlay: any;
|
||||||
hand?: any;
|
pointerInputMode: 'controller';
|
||||||
handAimLatch?: PalmAimLatch;
|
|
||||||
handPointerOverlay?: any;
|
|
||||||
pointerInputMode: PointerInputMode;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const tempMatrix = new THREE.Matrix4();
|
const tempMatrix = new THREE.Matrix4();
|
||||||
|
|
||||||
export function createVrInputRig(scene: any, renderer: any, onSelectStart: (event: any) => void): VrInputRig {
|
export function createVrInputRig(scene: any, renderer: any, onSelectStart: (event: any) => void): VrInputRig {
|
||||||
const overlayVisibility = new VrOverlayVisibility();
|
const overlayVisibility = new VrOverlayVisibility();
|
||||||
const handPointerOverlays: HandPointerOverlay[] = [];
|
|
||||||
const inputSources: VrInputSource[] = [];
|
const inputSources: VrInputSource[] = [];
|
||||||
const raycaster = createPointerRaycaster();
|
const raycaster = createPointerRaycaster();
|
||||||
const hoverRaycaster = createPointerRaycaster();
|
const hoverRaycaster = createPointerRaycaster();
|
||||||
@@ -97,23 +68,15 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
|
|||||||
rememberPointerInputMode(inputSource, event, 'controller');
|
rememberPointerInputMode(inputSource, event, 'controller');
|
||||||
});
|
});
|
||||||
controller.addEventListener('selectstart', (event: any) => {
|
controller.addEventListener('selectstart', (event: any) => {
|
||||||
const timestamp = getEventTimestamp(event);
|
rememberPointerInputMode(inputSource, event, 'controller');
|
||||||
rememberPointerInputMode(inputSource, event, inputSource.pointerInputMode);
|
overlayVisibility.show(getEventTimestamp(event));
|
||||||
if (shouldUseHandPointer(inputSource)) {
|
|
||||||
beginPalmAimSelection(controller.userData?.vrwpHandAimLatch, timestamp);
|
|
||||||
}
|
|
||||||
overlayVisibility.show(timestamp);
|
|
||||||
onSelectStart(event);
|
onSelectStart(event);
|
||||||
});
|
});
|
||||||
controller.addEventListener('selectend', () => {
|
controller.addEventListener('selectend', () => {
|
||||||
endPalmAimSelection(controller.userData?.vrwpHandAimLatch);
|
|
||||||
if (activeSeekDrag?.inputSource.controller === controller) {
|
if (activeSeekDrag?.inputSource.controller === controller) {
|
||||||
activeSeekDrag = null;
|
activeSeekDrag = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
controller.addEventListener('select', () => {
|
|
||||||
endPalmAimSelection(controller.userData?.vrwpHandAimLatch);
|
|
||||||
});
|
|
||||||
bindOverlayActivity(controller, overlayVisibility);
|
bindOverlayActivity(controller, overlayVisibility);
|
||||||
controller.add(controllerPointerOverlay);
|
controller.add(controllerPointerOverlay);
|
||||||
scene.add(controller);
|
scene.add(controller);
|
||||||
@@ -127,43 +90,6 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
|
|||||||
grip.add(createControllerOverlay(index, overlayVisibility));
|
grip.add(createControllerOverlay(index, overlayVisibility));
|
||||||
scene.add(grip);
|
scene.add(grip);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hand = renderer.xr.getHand?.(index);
|
|
||||||
if (hand) {
|
|
||||||
const handAimLatch = createPalmAimLatch();
|
|
||||||
inputSource.hand = hand;
|
|
||||||
inputSource.handAimLatch = handAimLatch;
|
|
||||||
controller.userData = {
|
|
||||||
...controller.userData,
|
|
||||||
vrwpHand: hand,
|
|
||||||
vrwpHandAimLatch: handAimLatch
|
|
||||||
};
|
|
||||||
hand.userData = {
|
|
||||||
...hand.userData,
|
|
||||||
vrwpAimLatch: handAimLatch
|
|
||||||
};
|
|
||||||
bindOverlayActivity(hand, overlayVisibility);
|
|
||||||
rememberHandedness(hand, { data: hand.inputState });
|
|
||||||
createHandOverlay(hand, index, overlayVisibility);
|
|
||||||
hand.addEventListener?.('connected', (event: any) => {
|
|
||||||
rememberPointerInputMode(inputSource, event, 'hand');
|
|
||||||
rememberHandedness(hand, event);
|
|
||||||
createHandOverlay(hand, index, overlayVisibility);
|
|
||||||
overlayVisibility.show();
|
|
||||||
});
|
|
||||||
scene.add(hand);
|
|
||||||
|
|
||||||
const handPointerOverlay = createWorldPointerOverlay(index, overlayVisibility);
|
|
||||||
inputSource.handPointerOverlay = handPointerOverlay;
|
|
||||||
scene.add(handPointerOverlay);
|
|
||||||
handPointerOverlays.push({
|
|
||||||
fallbackPointerOverlay: controllerPointerOverlay,
|
|
||||||
hand,
|
|
||||||
handAimLatch,
|
|
||||||
inputSource,
|
|
||||||
pointerOverlay: handPointerOverlay
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
overlayVisibility.hideImmediately();
|
overlayVisibility.hideImmediately();
|
||||||
@@ -182,9 +108,8 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
|
|||||||
raycaster,
|
raycaster,
|
||||||
showOverlays: (timestamp?: number) => overlayVisibility.show(timestamp),
|
showOverlays: (timestamp?: number) => overlayVisibility.show(timestamp),
|
||||||
update: (timestamp: number, hoverTargets: any[] = []) => {
|
update: (timestamp: number, hoverTargets: any[] = []) => {
|
||||||
updateHandPointerOverlays(handPointerOverlays, timestamp);
|
updateActiveSeekDrag(activeSeekDrag, dragRaycaster);
|
||||||
updateActiveSeekDrag(activeSeekDrag, dragRaycaster, timestamp);
|
const isHovering = updateInputPointerIntersections(inputSources, hoverTargets, hoverRaycaster);
|
||||||
const isHovering = updateInputPointerIntersections(inputSources, hoverTargets, hoverRaycaster, timestamp);
|
|
||||||
if (isHovering) {
|
if (isHovering) {
|
||||||
overlayVisibility.show(timestamp);
|
overlayVisibility.show(timestamp);
|
||||||
}
|
}
|
||||||
@@ -208,15 +133,14 @@ function getInputSourceByController(inputSources: VrInputSource[], controller: a
|
|||||||
function updateInputPointerIntersections(
|
function updateInputPointerIntersections(
|
||||||
inputSources: VrInputSource[],
|
inputSources: VrInputSource[],
|
||||||
hoverTargets: any[],
|
hoverTargets: any[],
|
||||||
hoverRaycaster: any,
|
hoverRaycaster: any
|
||||||
timestamp: number
|
|
||||||
): boolean {
|
): boolean {
|
||||||
let isHoveringAnyTarget = false;
|
let isHoveringAnyTarget = false;
|
||||||
|
|
||||||
inputSources.forEach((inputSource) => {
|
inputSources.forEach((inputSource) => {
|
||||||
resetInputPointerLengths(inputSource);
|
resetInputPointerLengths(inputSource);
|
||||||
const aimRay = getInputSourceAimRay(inputSource, timestamp);
|
const aimRay = getControllerAimRay(inputSource.controller);
|
||||||
const pointerOverlay = getActivePointerOverlay(inputSource);
|
const pointerOverlay = inputSource.controllerPointerOverlay;
|
||||||
if (!aimRay || !pointerOverlay || hoverTargets.length === 0) {
|
if (!aimRay || !pointerOverlay || hoverTargets.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -228,25 +152,26 @@ function updateInputPointerIntersections(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isHoveringAnyTarget = true;
|
|
||||||
setPointerOverlayLength(pointerOverlay, getPointerIntersectionLength(intersections[0].distance));
|
setPointerOverlayLength(pointerOverlay, getPointerIntersectionLength(intersections[0].distance));
|
||||||
|
isHoveringAnyTarget = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return isHoveringAnyTarget;
|
return isHoveringAnyTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateActiveSeekDrag(activeSeekDrag: ActiveSeekDrag | null, dragRaycaster: any, timestamp: number): void {
|
function updateActiveSeekDrag(activeSeekDrag: ActiveSeekDrag | null, dragRaycaster: any): void {
|
||||||
if (!activeSeekDrag?.panel.seekBarHitAreaMesh) {
|
if (!activeSeekDrag?.panel.seekBarHitAreaMesh) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const aimRay = getInputSourceAimRay(activeSeekDrag.inputSource, timestamp, { preferLiveHandAim: true });
|
const aimRay = getControllerAimRay(activeSeekDrag.inputSource.controller);
|
||||||
if (!aimRay) {
|
if (!aimRay) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dragRaycaster.ray.origin.copy(aimRay.origin);
|
dragRaycaster.ray.origin.copy(aimRay.origin);
|
||||||
dragRaycaster.ray.direction.copy(aimRay.direction);
|
dragRaycaster.ray.direction.copy(aimRay.direction);
|
||||||
|
|
||||||
const intersections = dragRaycaster.intersectObject(activeSeekDrag.panel.seekBarHitAreaMesh, true);
|
const intersections = dragRaycaster.intersectObject(activeSeekDrag.panel.seekBarHitAreaMesh, true);
|
||||||
if (intersections.length === 0) {
|
if (intersections.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -255,33 +180,6 @@ function updateActiveSeekDrag(activeSeekDrag: ActiveSeekDrag | null, dragRaycast
|
|||||||
activeSeekDrag.onSeek(getSeekProgressFromIntersection(activeSeekDrag.panel, intersections[0].point));
|
activeSeekDrag.onSeek(getSeekProgressFromIntersection(activeSeekDrag.panel, intersections[0].point));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInputSourceAimRay(
|
|
||||||
inputSource: VrInputSource,
|
|
||||||
timestamp: number,
|
|
||||||
{ preferLiveHandAim = false }: { preferLiveHandAim?: boolean } = {}
|
|
||||||
): AimRay | null {
|
|
||||||
if (shouldUseHandPointer(inputSource) && inputSource.hand && inputSource.handAimLatch) {
|
|
||||||
if (preferLiveHandAim) {
|
|
||||||
const handRay = getHandAimRay(inputSource.hand);
|
|
||||||
if (handRay) {
|
|
||||||
return handRay;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const latchedRay = getPalmAimSelectionRay(inputSource.handAimLatch, timestamp);
|
|
||||||
if (latchedRay) {
|
|
||||||
return toAimRay(latchedRay);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handRay = getHandAimRay(inputSource.hand);
|
|
||||||
if (handRay) {
|
|
||||||
return handRay;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return getControllerAimRay(inputSource.controller);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getControllerAimRay(controller: any): AimRay | null {
|
function getControllerAimRay(controller: any): AimRay | null {
|
||||||
if (!controller) {
|
if (!controller) {
|
||||||
return null;
|
return null;
|
||||||
@@ -294,72 +192,6 @@ function getControllerAimRay(controller: any): AimRay | null {
|
|||||||
return { direction, origin };
|
return { direction, origin };
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetInputPointerLengths(inputSource: VrInputSource): void {
|
|
||||||
setPointerOverlayLength(inputSource.controllerPointerOverlay, POINTER_LENGTH);
|
|
||||||
if (inputSource.handPointerOverlay) {
|
|
||||||
setPointerOverlayLength(inputSource.handPointerOverlay, POINTER_LENGTH);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActivePointerOverlay(inputSource: VrInputSource): any {
|
|
||||||
if (inputSource.handPointerOverlay && inputSource.handPointerOverlay.userData?.vrwpOverlayAvailable !== false) {
|
|
||||||
return inputSource.handPointerOverlay;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inputSource.controllerPointerOverlay && inputSource.controllerPointerOverlay.userData?.vrwpOverlayAvailable !== false) {
|
|
||||||
return inputSource.controllerPointerOverlay;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPointerIntersectionLength(distance: number): number {
|
|
||||||
return Math.max(
|
|
||||||
POINTER_MIN_LENGTH,
|
|
||||||
Math.min(POINTER_LENGTH, distance - POINTER_HIT_SURFACE_OFFSET)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHandPointerOverlays(handPointerOverlays: HandPointerOverlay[], timestamp: number): void {
|
|
||||||
handPointerOverlays.forEach(({ fallbackPointerOverlay, hand, handAimLatch, inputSource, pointerOverlay }) => {
|
|
||||||
if (!shouldUseHandPointer(inputSource)) {
|
|
||||||
pointerOverlay.userData = {
|
|
||||||
...pointerOverlay.userData,
|
|
||||||
vrwpOverlayAvailable: false
|
|
||||||
};
|
|
||||||
fallbackPointerOverlay.userData = {
|
|
||||||
...fallbackPointerOverlay.userData,
|
|
||||||
vrwpOverlayAvailable: true
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentHandRay = getHandAimRay(hand);
|
|
||||||
if (currentHandRay) {
|
|
||||||
recordStablePalmAimRay(handAimLatch, toPalmAimRay(currentHandRay), timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectionPalmRay = getPalmAimSelectionRay(handAimLatch, timestamp);
|
|
||||||
const displayHandRay = selectionPalmRay ? toAimRay(selectionPalmRay) : currentHandRay;
|
|
||||||
const hasHandRay = Boolean(displayHandRay);
|
|
||||||
pointerOverlay.userData = {
|
|
||||||
...pointerOverlay.userData,
|
|
||||||
vrwpOverlayAvailable: hasHandRay
|
|
||||||
};
|
|
||||||
fallbackPointerOverlay.userData = {
|
|
||||||
...fallbackPointerOverlay.userData,
|
|
||||||
vrwpOverlayAvailable: !hasHandRay
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!displayHandRay) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pointerOverlay.position.copy(displayHandRay.origin);
|
|
||||||
pointerOverlay.quaternion.setFromUnitVectors(DEFAULT_RAY_DIRECTION, displayHandRay.direction);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEventTimestamp(event: any): number {
|
function getEventTimestamp(event: any): number {
|
||||||
return Number.isFinite(event?.timeStamp) ? event.timeStamp : performance.now();
|
return Number.isFinite(event?.timeStamp) ? event.timeStamp : performance.now();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
|||||||
|
|
||||||
export type PointerOverlayInputSource = {
|
export type PointerOverlayInputSource = {
|
||||||
controllerPointerOverlay: any;
|
controllerPointerOverlay: any;
|
||||||
handPointerOverlay?: any;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VrOverlayVisibilityOptions = {
|
export type VrOverlayVisibilityOptions = {
|
||||||
@@ -10,41 +9,13 @@ export type VrOverlayVisibilityOptions = {
|
|||||||
hideDelayMs?: number;
|
hideDelayMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const INPUT_OVERLAY_HIDE_DELAY_MS = 2500;
|
export const INPUT_OVERLAY_HIDE_DELAY_MS = 1600;
|
||||||
export const INPUT_OVERLAY_FADE_DURATION_MS = 200;
|
export const INPUT_OVERLAY_FADE_DURATION_MS = 260;
|
||||||
export const INPUT_OVERLAY_RENDER_ORDER = 10000;
|
export const INPUT_OVERLAY_RENDER_ORDER = 10000;
|
||||||
export const POINTER_LENGTH = 5;
|
export const POINTER_LENGTH = 5;
|
||||||
export const POINTER_MIN_LENGTH = 0.06;
|
export const POINTER_MIN_LENGTH = 0.06;
|
||||||
export const POINTER_HIT_SURFACE_OFFSET = 0.015;
|
export const POINTER_HIT_SURFACE_OFFSET = 0.015;
|
||||||
|
|
||||||
const HAND_JOINT_NAMES = [
|
|
||||||
'wrist',
|
|
||||||
'thumb-metacarpal',
|
|
||||||
'thumb-phalanx-proximal',
|
|
||||||
'thumb-phalanx-distal',
|
|
||||||
'thumb-tip',
|
|
||||||
'index-finger-metacarpal',
|
|
||||||
'index-finger-phalanx-proximal',
|
|
||||||
'index-finger-phalanx-intermediate',
|
|
||||||
'index-finger-phalanx-distal',
|
|
||||||
'index-finger-tip',
|
|
||||||
'middle-finger-metacarpal',
|
|
||||||
'middle-finger-phalanx-proximal',
|
|
||||||
'middle-finger-phalanx-intermediate',
|
|
||||||
'middle-finger-phalanx-distal',
|
|
||||||
'middle-finger-tip',
|
|
||||||
'ring-finger-metacarpal',
|
|
||||||
'ring-finger-phalanx-proximal',
|
|
||||||
'ring-finger-phalanx-intermediate',
|
|
||||||
'ring-finger-phalanx-distal',
|
|
||||||
'ring-finger-tip',
|
|
||||||
'pinky-finger-metacarpal',
|
|
||||||
'pinky-finger-phalanx-proximal',
|
|
||||||
'pinky-finger-phalanx-intermediate',
|
|
||||||
'pinky-finger-phalanx-distal',
|
|
||||||
'pinky-finger-tip'
|
|
||||||
];
|
|
||||||
|
|
||||||
export class VrOverlayVisibility {
|
export class VrOverlayVisibility {
|
||||||
private readonly fadeDurationMs: number;
|
private readonly fadeDurationMs: number;
|
||||||
private readonly hideDelayMs: number;
|
private readonly hideDelayMs: number;
|
||||||
@@ -123,7 +94,7 @@ export class VrOverlayVisibility {
|
|||||||
object.traverse?.((child: any) => {
|
object.traverse?.((child: any) => {
|
||||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||||
materials.filter(Boolean).forEach((material: any) => {
|
materials.filter(Boolean).forEach((material: any) => {
|
||||||
material.opacity = opacity;
|
material.opacity = opacity * getOverlayMaterialMaxOpacity(material);
|
||||||
material.transparent = true;
|
material.transparent = true;
|
||||||
material.depthTest = false;
|
material.depthTest = false;
|
||||||
material.depthWrite = false;
|
material.depthWrite = false;
|
||||||
@@ -141,9 +112,7 @@ export function bindOverlayActivity(target: any, overlayVisibility: VrOverlayVis
|
|||||||
'selectend',
|
'selectend',
|
||||||
'squeezestart',
|
'squeezestart',
|
||||||
'squeeze',
|
'squeeze',
|
||||||
'squeezeend',
|
'squeezeend'
|
||||||
'pinchstart',
|
|
||||||
'pinchend'
|
|
||||||
].forEach((eventName) => {
|
].forEach((eventName) => {
|
||||||
target.addEventListener?.(eventName, () => overlayVisibility.show());
|
target.addEventListener?.(eventName, () => overlayVisibility.show());
|
||||||
});
|
});
|
||||||
@@ -153,7 +122,7 @@ export function createPointerOverlay(index: number, overlayVisibility: VrOverlay
|
|||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
group.name = `vrPointerOverlay${index}`;
|
group.name = `vrPointerOverlay${index}`;
|
||||||
|
|
||||||
const pointerMaterial = createOverlayLineMaterial(index === 0 ? 0x8fd8ff : 0xffc16b, 0.75);
|
const pointerMaterial = createOverlayLineMaterial(index === 0 ? 0xb9e8ff : 0xffd99a, 0.28);
|
||||||
const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
|
const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
|
||||||
new THREE.Vector3(0, 0, 0),
|
new THREE.Vector3(0, 0, 0),
|
||||||
new THREE.Vector3(0, 0, -POINTER_LENGTH)
|
new THREE.Vector3(0, 0, -POINTER_LENGTH)
|
||||||
@@ -163,8 +132,8 @@ export function createPointerOverlay(index: number, overlayVisibility: VrOverlay
|
|||||||
pointerLine.renderOrder = INPUT_OVERLAY_RENDER_ORDER;
|
pointerLine.renderOrder = INPUT_OVERLAY_RENDER_ORDER;
|
||||||
group.add(pointerLine);
|
group.add(pointerLine);
|
||||||
|
|
||||||
const tipMaterial = createOverlayMeshMaterial(index === 0 ? 0x8fd8ff : 0xffc16b, 0.9);
|
const tipMaterial = createOverlayMeshMaterial(index === 0 ? 0xd8f5ff : 0xffe6ba, 0.42);
|
||||||
const tipMesh = new THREE.Mesh(new THREE.SphereGeometry(0.018, 12, 8), tipMaterial);
|
const tipMesh = new THREE.Mesh(new THREE.SphereGeometry(0.01, 10, 6), tipMaterial);
|
||||||
tipMesh.name = `vrPointerTip${index}`;
|
tipMesh.name = `vrPointerTip${index}`;
|
||||||
tipMesh.position.z = -POINTER_LENGTH;
|
tipMesh.position.z = -POINTER_LENGTH;
|
||||||
tipMesh.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 1;
|
tipMesh.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 1;
|
||||||
@@ -181,21 +150,11 @@ export function createPointerOverlay(index: number, overlayVisibility: VrOverlay
|
|||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createWorldPointerOverlay(index: number, overlayVisibility: VrOverlayVisibility): any {
|
|
||||||
const group = createPointerOverlay(index, overlayVisibility);
|
|
||||||
group.name = `vrHandPointerOverlay${index}`;
|
|
||||||
group.userData = {
|
|
||||||
...group.userData,
|
|
||||||
vrwpOverlayAvailable: false
|
|
||||||
};
|
|
||||||
return group;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createControllerOverlay(index: number, overlayVisibility: VrOverlayVisibility): any {
|
export function createControllerOverlay(index: number, overlayVisibility: VrOverlayVisibility): any {
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
group.name = `vrControllerOverlay${index}`;
|
group.name = `vrControllerOverlay${index}`;
|
||||||
|
|
||||||
const material = createOverlayLineMaterial(index === 0 ? 0x8fd8ff : 0xffc16b, 0.8);
|
const material = createOverlayLineMaterial(index === 0 ? 0xb9e8ff : 0xffd99a, 0.24);
|
||||||
const outlineGeometry = new THREE.BufferGeometry().setFromPoints([
|
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.045, -0.025, -0.08),
|
new THREE.Vector3(0.045, -0.025, -0.08),
|
||||||
@@ -210,8 +169,8 @@ export function createControllerOverlay(index: number, overlayVisibility: VrOver
|
|||||||
outline.renderOrder = INPUT_OVERLAY_RENDER_ORDER;
|
outline.renderOrder = INPUT_OVERLAY_RENDER_ORDER;
|
||||||
group.add(outline);
|
group.add(outline);
|
||||||
|
|
||||||
const originMaterial = createOverlayMeshMaterial(0xffffff, 0.75);
|
const originMaterial = createOverlayMeshMaterial(0xffffff, 0.28);
|
||||||
const origin = new THREE.Mesh(new THREE.SphereGeometry(0.012, 10, 6), originMaterial);
|
const origin = new THREE.Mesh(new THREE.SphereGeometry(0.007, 8, 5), originMaterial);
|
||||||
origin.name = `vrControllerOrigin${index}`;
|
origin.name = `vrControllerOrigin${index}`;
|
||||||
origin.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 1;
|
origin.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 1;
|
||||||
group.add(origin);
|
group.add(origin);
|
||||||
@@ -220,39 +179,8 @@ export function createControllerOverlay(index: number, overlayVisibility: VrOver
|
|||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHandOverlay(hand: any, index: number, overlayVisibility: VrOverlayVisibility): void {
|
|
||||||
const joints = getHandJoints(hand);
|
|
||||||
if (joints.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const material = createOverlayMeshMaterial(index === 0 ? 0x8fd8ff : 0xffc16b, 0.85);
|
|
||||||
joints.forEach(({ joint, name }) => {
|
|
||||||
if (!joint || joint.userData?.vrwpHandOverlayMarker) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTip = name.endsWith('tip');
|
|
||||||
const isWrist = name === 'wrist';
|
|
||||||
const radius = isWrist ? 0.014 : isTip ? 0.011 : 0.008;
|
|
||||||
const marker = new THREE.Mesh(new THREE.SphereGeometry(radius, 10, 6), material);
|
|
||||||
marker.name = `vrHandJointOverlay${index}-${name}`;
|
|
||||||
marker.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 2;
|
|
||||||
marker.frustumCulled = false;
|
|
||||||
joint.add(marker);
|
|
||||||
joint.userData = {
|
|
||||||
...joint.userData,
|
|
||||||
vrwpHandOverlayMarker: marker
|
|
||||||
};
|
|
||||||
overlayVisibility.register(marker);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetInputPointerLengths(inputSource: PointerOverlayInputSource): void {
|
export function resetInputPointerLengths(inputSource: PointerOverlayInputSource): void {
|
||||||
setPointerOverlayLength(inputSource.controllerPointerOverlay, POINTER_LENGTH);
|
setPointerOverlayLength(inputSource.controllerPointerOverlay, POINTER_LENGTH);
|
||||||
if (inputSource.handPointerOverlay) {
|
|
||||||
setPointerOverlayLength(inputSource.handPointerOverlay, POINTER_LENGTH);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPointerIntersectionLength(distance: number): number {
|
export function getPointerIntersectionLength(distance: number): number {
|
||||||
@@ -289,40 +217,36 @@ export function setPointerOverlayLength(pointerOverlay: any, length: number): vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createOverlayLineMaterial(color: number, opacity: number): any {
|
export function createOverlayLineMaterial(color: number, opacity: number): any {
|
||||||
return new THREE.LineBasicMaterial({
|
const material = new THREE.LineBasicMaterial({
|
||||||
color,
|
color,
|
||||||
depthTest: false,
|
depthTest: false,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
opacity,
|
opacity,
|
||||||
transparent: true
|
transparent: true
|
||||||
});
|
});
|
||||||
|
material.userData = {
|
||||||
|
...material.userData,
|
||||||
|
vrwpOverlayMaxOpacity: opacity
|
||||||
|
};
|
||||||
|
return material;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOverlayMeshMaterial(color: number, opacity: number): any {
|
export function createOverlayMeshMaterial(color: number, opacity: number): any {
|
||||||
return new THREE.MeshBasicMaterial({
|
const material = new THREE.MeshBasicMaterial({
|
||||||
color,
|
color,
|
||||||
depthTest: false,
|
depthTest: false,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
opacity,
|
opacity,
|
||||||
transparent: true
|
transparent: true
|
||||||
});
|
});
|
||||||
|
material.userData = {
|
||||||
|
...material.userData,
|
||||||
|
vrwpOverlayMaxOpacity: opacity
|
||||||
|
};
|
||||||
|
return material;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHandJoints(hand: any): Array<{ joint: any; name: string }> {
|
function getOverlayMaterialMaxOpacity(material: any): number {
|
||||||
const joints = hand?.joints;
|
const maxOpacity = material.userData?.vrwpOverlayMaxOpacity;
|
||||||
if (!joints) {
|
return Number.isFinite(maxOpacity) ? maxOpacity : 1;
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const namedJoints = HAND_JOINT_NAMES
|
|
||||||
.map((name) => ({ joint: joints[name], name }))
|
|
||||||
.filter(({ joint }) => Boolean(joint));
|
|
||||||
|
|
||||||
if (namedJoints.length > 0) {
|
|
||||||
return namedJoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(joints)
|
|
||||||
.map(([name, joint]) => ({ joint, name }))
|
|
||||||
.filter(({ joint }) => Boolean(joint));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,6 @@ import {
|
|||||||
getSeekProgressFromIntersection,
|
getSeekProgressFromIntersection,
|
||||||
type VrControlPanel
|
type VrControlPanel
|
||||||
} from './vr-control-panel.js';
|
} from './vr-control-panel.js';
|
||||||
import { getPalmAimSelectionRay } from './hand-aim.js';
|
|
||||||
import {
|
|
||||||
getHandAimRay,
|
|
||||||
toAimRay,
|
|
||||||
type AimRay
|
|
||||||
} from './hand-aim-three.js';
|
|
||||||
import { shouldUseHandPointer } from './input-mode.js';
|
|
||||||
|
|
||||||
type VrControllerSelectionOptions = {
|
type VrControllerSelectionOptions = {
|
||||||
beginSeekDrag?: (controller: any) => void;
|
beginSeekDrag?: (controller: any) => void;
|
||||||
@@ -100,28 +93,8 @@ function togglePanel(options: VrControllerSelectionOptions): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applySelectionRay(controller: any, raycaster: any): void {
|
function applySelectionRay(controller: any, raycaster: any): void {
|
||||||
const handRay = shouldUseHandPointer(controller.userData?.vrwpInputSource)
|
|
||||||
? getSelectionHandAimRay(controller) || getHandAimRay(controller.userData?.vrwpHand)
|
|
||||||
: null;
|
|
||||||
if (handRay) {
|
|
||||||
raycaster.ray.origin.copy(handRay.origin);
|
|
||||||
raycaster.ray.direction.copy(handRay.direction);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.updateMatrixWorld();
|
controller.updateMatrixWorld();
|
||||||
tempMatrix.identity().extractRotation(controller.matrixWorld);
|
tempMatrix.identity().extractRotation(controller.matrixWorld);
|
||||||
raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
|
raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
|
||||||
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
|
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectionHandAimRay(controller: any): AimRay | null {
|
|
||||||
const latch = controller.userData?.vrwpHandAimLatch ||
|
|
||||||
controller.userData?.vrwpHand?.userData?.vrwpAimLatch;
|
|
||||||
if (!latch) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const palmAimRay = getPalmAimSelectionRay(latch, performance.now());
|
|
||||||
return palmAimRay ? toAimRay(palmAimRay) : null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import {
|
|||||||
setVrPanelOpacity,
|
setVrPanelOpacity,
|
||||||
type VrControlPanel
|
type VrControlPanel
|
||||||
} from './vr-control-panel.js';
|
} from './vr-control-panel.js';
|
||||||
|
import { DEFAULT_MENU_AUTO_HIDE_DELAY_MS } from '../utils/control-panel-timing.js';
|
||||||
|
|
||||||
const FADE_DURATION_MS = 200;
|
const FADE_DURATION_MS = 200;
|
||||||
const AUTO_HIDE_DELAY_MS = 10000;
|
|
||||||
|
|
||||||
export class VrPanelVisibility {
|
export class VrPanelVisibility {
|
||||||
private hideTimeout: number | undefined;
|
private hideTimeout: number | undefined;
|
||||||
@@ -28,15 +28,15 @@ export class VrPanelVisibility {
|
|||||||
this.hideImmediately();
|
this.hideImmediately();
|
||||||
}
|
}
|
||||||
|
|
||||||
show(): void {
|
show(autoHideDelayMs = DEFAULT_MENU_AUTO_HIDE_DELAY_MS): void {
|
||||||
this.showWithAutoHide(true);
|
this.showWithAutoHide(true, autoHideDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
showPersistent(): void {
|
showPersistent(): void {
|
||||||
this.showWithAutoHide(false);
|
this.showWithAutoHide(false, DEFAULT_MENU_AUTO_HIDE_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
private showWithAutoHide(shouldAutoHide: boolean): void {
|
private showWithAutoHide(shouldAutoHide: boolean, autoHideDelayMs: number): void {
|
||||||
if (this.panel) this.panel.group.visible = true;
|
if (this.panel) this.panel.group.visible = true;
|
||||||
this.clearHideTimeout();
|
this.clearHideTimeout();
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export class VrPanelVisibility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (shouldAutoHide) {
|
if (shouldAutoHide) {
|
||||||
this.hideTimeout = window.setTimeout(() => this.hide(), AUTO_HIDE_DELAY_MS);
|
this.hideTimeout = window.setTimeout(() => this.hide(), autoHideDelayMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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.');
|
||||||
|
}
|
||||||
@@ -146,6 +146,137 @@ a {
|
|||||||
color: #275425;
|
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) {
|
@media (max-width: 640px) {
|
||||||
.demo-page {
|
.demo-page {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|||||||
@@ -12,45 +12,49 @@
|
|||||||
<header class="demo-topbar">
|
<header class="demo-topbar">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="demo-brand">VR Web Player Tests</h1>
|
<h1 class="demo-brand">VR Web Player Tests</h1>
|
||||||
<p class="demo-kicker">Open a focused page for each media and projection combination.</p>
|
<p class="demo-kicker">Pick local media, or launch one of the two bundled SBS image samples.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="demo-grid" aria-label="Player test pages">
|
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
||||||
<a class="demo-card" href="./test-3d-image.html">
|
|
||||||
<h2>3D Image</h2>
|
|
||||||
<p>Flat SBS image on a rectangular plane.</p>
|
|
||||||
<span class="demo-meta">image / plane</span>
|
|
||||||
</a>
|
|
||||||
<a class="demo-card" href="./test-vr180-3d-image.html">
|
|
||||||
<h2>VR180 3D Image</h2>
|
|
||||||
<p>SBS image on the VR180 hemisphere.</p>
|
|
||||||
<span class="demo-meta">image / vr180</span>
|
|
||||||
</a>
|
|
||||||
<a class="demo-card" href="./test-3d-image-carousel.html">
|
|
||||||
<h2>3D Image Carousel</h2>
|
|
||||||
<p>Flat SBS image carousel with previous and next controls.</p>
|
|
||||||
<span class="demo-meta">image carousel / plane</span>
|
|
||||||
</a>
|
|
||||||
<a class="demo-card" href="./test-vr180-3d-image-carousel.html">
|
|
||||||
<h2>VR180 Image Carousel</h2>
|
|
||||||
<p>VR180 SBS image carousel in one immersive session.</p>
|
|
||||||
<span class="demo-meta">image carousel / vr180</span>
|
|
||||||
</a>
|
|
||||||
<a class="demo-card" href="./test-3d-video.html">
|
|
||||||
<h2>3D Video</h2>
|
|
||||||
<p>Flat SBS video on a rectangular plane.</p>
|
|
||||||
<span class="demo-meta">video / plane</span>
|
|
||||||
</a>
|
|
||||||
<a class="demo-card" href="./test-vr180-3d-video.html">
|
|
||||||
<h2>VR180 3D Video</h2>
|
|
||||||
<p>SBS video on the VR180 hemisphere.</p>
|
|
||||||
<span class="demo-meta">video / vr180</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<p class="demo-note">Image tests use files in <code>../media/</code>. Video tests expect <code>../media/sbs-video.mp4</code>.</p>
|
<div 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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<script type="module" src="./demo-xr-status.js"></script>
|
||||||
|
<script type="module" src="../vr180player/vr180-player.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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);
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
||||||
<title>3D Image Carousel Test</title>
|
|
||||||
<link rel="stylesheet" href="./demo.css">
|
|
||||||
<link rel="stylesheet" href="../vr180player/vr180-player.css" data-vr-web-player-stylesheet>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="demo-page">
|
|
||||||
<div class="demo-shell demo-player">
|
|
||||||
<header class="demo-topbar">
|
|
||||||
<div>
|
|
||||||
<h1 class="demo-brand">3D Image Carousel</h1>
|
|
||||||
<p class="demo-kicker">Projection: plane. Media: multiple side-by-side images.</p>
|
|
||||||
</div>
|
|
||||||
<a class="demo-back" href="./index.html">Back</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
|
||||||
|
|
||||||
<div class="demo-player-frame" data-vr-web-player data-projection="plane" data-carousel>
|
|
||||||
<img src="../media/169_3d_test.png" alt="Demo SBS image one" title="3D Image Plane 1" crossorigin="anonymous">
|
|
||||||
<img src="../media/169_3d_test.png?slide=2" alt="Demo SBS image two" title="3D Image Plane 2" crossorigin="anonymous">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<script type="module" src="./demo-xr-status.js"></script>
|
|
||||||
<script type="module" src="../vr180player/vr180-player.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
||||||
<title>3D Image Test</title>
|
|
||||||
<link rel="stylesheet" href="./demo.css">
|
|
||||||
<link rel="stylesheet" href="../vr180player/vr180-player.css" data-vr-web-player-stylesheet>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="demo-page">
|
|
||||||
<div class="demo-shell demo-player">
|
|
||||||
<header class="demo-topbar">
|
|
||||||
<div>
|
|
||||||
<h1 class="demo-brand">3D Image</h1>
|
|
||||||
<p class="demo-kicker">Projection: plane. Media: side-by-side image.</p>
|
|
||||||
</div>
|
|
||||||
<a class="demo-back" href="./index.html">Back</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
|
||||||
|
|
||||||
<div class="demo-player-frame" data-vr-web-player data-projection="plane">
|
|
||||||
<img src="../media/169_3d_test.png" alt="Demo SBS image" title="3D Image Plane" crossorigin="anonymous">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<script type="module" src="./demo-xr-status.js"></script>
|
|
||||||
<script type="module" src="../vr180player/vr180-player.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
||||||
<title>3D Video Test</title>
|
|
||||||
<link rel="stylesheet" href="./demo.css">
|
|
||||||
<link rel="stylesheet" href="../vr180player/vr180-player.css" data-vr-web-player-stylesheet>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="demo-page">
|
|
||||||
<div class="demo-shell demo-player">
|
|
||||||
<header class="demo-topbar">
|
|
||||||
<div>
|
|
||||||
<h1 class="demo-brand">3D Video</h1>
|
|
||||||
<p class="demo-kicker">Projection: plane. Media: side-by-side video.</p>
|
|
||||||
</div>
|
|
||||||
<a class="demo-back" href="./index.html">Back</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
|
||||||
|
|
||||||
<div class="demo-player-frame" data-vr-web-player data-projection="plane">
|
|
||||||
<video poster="../poster.jpg" title="3D Video Plane" crossorigin="anonymous" playsinline preload="metadata">
|
|
||||||
<source src="../media/sbs-video.mp4" type="video/mp4">
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<script type="module" src="./demo-xr-status.js"></script>
|
|
||||||
<script type="module" src="../vr180player/vr180-player.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
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>
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
||||||
<title>VR180 3D Image Carousel Test</title>
|
|
||||||
<link rel="stylesheet" href="./demo.css">
|
|
||||||
<link rel="stylesheet" href="../vr180player/vr180-player.css" data-vr-web-player-stylesheet>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="demo-page">
|
|
||||||
<div class="demo-shell demo-player">
|
|
||||||
<header class="demo-topbar">
|
|
||||||
<div>
|
|
||||||
<h1 class="demo-brand">VR180 Image Carousel</h1>
|
|
||||||
<p class="demo-kicker">Projection: VR180. Media: multiple side-by-side images.</p>
|
|
||||||
</div>
|
|
||||||
<a class="demo-back" href="./index.html">Back</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
|
||||||
|
|
||||||
<div class="demo-player-frame" data-vr-web-player data-projection="vr180" data-carousel>
|
|
||||||
<img src="../media/VR180_SBS_Test.png" alt="Demo VR180 SBS image one" title="VR180 3D Image 1" crossorigin="anonymous">
|
|
||||||
<img src="../media/VR180_SBS_Test.png?slide=2" alt="Demo VR180 SBS image two" title="VR180 3D Image 2" crossorigin="anonymous">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<script type="module" src="./demo-xr-status.js"></script>
|
|
||||||
<script type="module" src="../vr180player/vr180-player.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
||||||
<title>VR180 3D Image Test</title>
|
|
||||||
<link rel="stylesheet" href="./demo.css">
|
|
||||||
<link rel="stylesheet" href="../vr180player/vr180-player.css" data-vr-web-player-stylesheet>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="demo-page">
|
|
||||||
<div class="demo-shell demo-player">
|
|
||||||
<header class="demo-topbar">
|
|
||||||
<div>
|
|
||||||
<h1 class="demo-brand">VR180 3D Image</h1>
|
|
||||||
<p class="demo-kicker">Projection: VR180. Media: side-by-side image.</p>
|
|
||||||
</div>
|
|
||||||
<a class="demo-back" href="./index.html">Back</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
|
||||||
|
|
||||||
<div class="demo-player-frame" data-vr-web-player data-projection="vr180">
|
|
||||||
<img src="../media/VR180_SBS_Test.png" alt="Demo VR180 SBS image" title="VR180 3D Image" crossorigin="anonymous">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<script type="module" src="./demo-xr-status.js"></script>
|
|
||||||
<script type="module" src="../vr180player/vr180-player.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
||||||
<title>VR180 3D Video Test</title>
|
|
||||||
<link rel="stylesheet" href="./demo.css">
|
|
||||||
<link rel="stylesheet" href="../vr180player/vr180-player.css" data-vr-web-player-stylesheet>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="demo-page">
|
|
||||||
<div class="demo-shell demo-player">
|
|
||||||
<header class="demo-topbar">
|
|
||||||
<div>
|
|
||||||
<h1 class="demo-brand">VR180 3D Video</h1>
|
|
||||||
<p class="demo-kicker">Projection: VR180. Media: side-by-side video.</p>
|
|
||||||
</div>
|
|
||||||
<a class="demo-back" href="./index.html">Back</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
|
||||||
|
|
||||||
<div class="demo-player-frame" data-vr-web-player data-projection="vr180">
|
|
||||||
<video poster="../poster.jpg" title="VR180 3D Video" crossorigin="anonymous" playsinline preload="metadata">
|
|
||||||
<source src="../media/Test.mp4" type="video/mp4">
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<script type="module" src="./demo-xr-status.js"></script>
|
|
||||||
<script type="module" src="../vr180player/vr180-player.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
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
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
import {
|
|
||||||
beginPalmAimSelection,
|
|
||||||
computePalmAimRay,
|
|
||||||
createPalmAimLatch,
|
|
||||||
endPalmAimSelection,
|
|
||||||
getPalmAimSelectionRay,
|
|
||||||
recordStablePalmAimRay
|
|
||||||
} from '../vr180player/xr/hand-aim.js';
|
|
||||||
|
|
||||||
const wrist = { x: 0, y: 0, z: 0 };
|
|
||||||
const middleMetacarpal = { x: 0, y: 0.1, z: 0 };
|
|
||||||
const ringMetacarpal = { x: 0.025, y: 0.1, z: 0 };
|
|
||||||
const straightAheadRay = {
|
|
||||||
direction: { x: 0, y: 0, z: -1 },
|
|
||||||
origin: { x: 0, y: 1.4, z: -0.04 }
|
|
||||||
};
|
|
||||||
const pinchedRay = {
|
|
||||||
direction: { x: 0.4, y: -0.2, z: -0.8 },
|
|
||||||
origin: { x: 0.04, y: 1.32, z: -0.03 }
|
|
||||||
};
|
|
||||||
|
|
||||||
test('computePalmAimRay tilts a right palm ray toward the finger-forward axis', () => {
|
|
||||||
const ray = computePalmAimRay({
|
|
||||||
handedness: 'right',
|
|
||||||
indexMetacarpal: { x: -0.045, y: 0.1, z: 0 },
|
|
||||||
middleMetacarpal: { x: -0.015, y: 0.1, z: 0 },
|
|
||||||
pinkyMetacarpal: { x: 0.045, y: 0.1, z: 0 },
|
|
||||||
ringMetacarpal: { x: 0.015, y: 0.1, z: 0 },
|
|
||||||
wrist
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(ray);
|
|
||||||
assert.equal(Math.round(ray.direction.x * 1000) / 1000, 0);
|
|
||||||
assert.equal(Math.round(ray.direction.y * 1000) / 1000, 0.643);
|
|
||||||
assert.equal(Math.round(ray.direction.z * 1000) / 1000, -0.766);
|
|
||||||
assert.equal(Math.round(ray.origin.y * 1000) / 1000, 0.084);
|
|
||||||
assert.equal(Math.round(ray.origin.z * 1000) / 1000, -0.027);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('computePalmAimRay flips the palm normal for a left hand while keeping forward tilt', () => {
|
|
||||||
const ray = computePalmAimRay({
|
|
||||||
handedness: 'left',
|
|
||||||
indexMetacarpal: { x: 0.045, y: 0.1, z: 0 },
|
|
||||||
middleMetacarpal: { x: 0.015, y: 0.1, z: 0 },
|
|
||||||
pinkyMetacarpal: { x: -0.045, y: 0.1, z: 0 },
|
|
||||||
ringMetacarpal: { x: -0.015, y: 0.1, z: 0 },
|
|
||||||
wrist
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(ray);
|
|
||||||
assert.equal(Math.round(ray.direction.y * 1000) / 1000, 0.643);
|
|
||||||
assert.equal(Math.round(ray.direction.z * 1000) / 1000, -0.766);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('computePalmAimRay returns null when palm joints cannot define a stable ray', () => {
|
|
||||||
assert.equal(computePalmAimRay({ handedness: 'right', wrist }), null);
|
|
||||||
assert.equal(computePalmAimRay({
|
|
||||||
handedness: 'right',
|
|
||||||
indexMetacarpal: { x: 0, y: 0.1, z: 0 },
|
|
||||||
pinkyMetacarpal: { x: 0, y: 0.1, z: 0 },
|
|
||||||
wrist
|
|
||||||
}), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('palm aim latch returns the latest fresh stable ray', () => {
|
|
||||||
const latch = createPalmAimLatch();
|
|
||||||
|
|
||||||
recordStablePalmAimRay(latch, straightAheadRay, 100);
|
|
||||||
|
|
||||||
assert.deepEqual(getPalmAimSelectionRay(latch, 120), straightAheadRay);
|
|
||||||
assert.equal(getPalmAimSelectionRay(latch, 500), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('palm aim latch freezes the pre-selection ray while selecting', () => {
|
|
||||||
const latch = createPalmAimLatch();
|
|
||||||
|
|
||||||
recordStablePalmAimRay(latch, straightAheadRay, 100);
|
|
||||||
assert.deepEqual(beginPalmAimSelection(latch, 120), straightAheadRay);
|
|
||||||
|
|
||||||
recordStablePalmAimRay(latch, pinchedRay, 130);
|
|
||||||
|
|
||||||
assert.deepEqual(getPalmAimSelectionRay(latch, 140), straightAheadRay);
|
|
||||||
assert.deepEqual(getPalmAimSelectionRay(latch, 1000), straightAheadRay);
|
|
||||||
|
|
||||||
endPalmAimSelection(latch);
|
|
||||||
recordStablePalmAimRay(latch, pinchedRay, 150);
|
|
||||||
|
|
||||||
assert.deepEqual(getPalmAimSelectionRay(latch, 160), pinchedRay);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('palm aim latch ignores stale rays when selection starts too late', () => {
|
|
||||||
const latch = createPalmAimLatch();
|
|
||||||
|
|
||||||
recordStablePalmAimRay(latch, straightAheadRay, 100);
|
|
||||||
|
|
||||||
assert.equal(beginPalmAimSelection(latch, 450), null);
|
|
||||||
assert.equal(getPalmAimSelectionRay(latch, 450), null);
|
|
||||||
});
|
|
||||||
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']
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -3,14 +3,13 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getPointerInputMode,
|
getPointerInputMode,
|
||||||
rememberPointerInputMode,
|
rememberPointerInputMode
|
||||||
shouldUseHandPointer
|
|
||||||
} from '../vr180player/xr/input-mode.js';
|
} from '../vr180player/xr/input-mode.js';
|
||||||
|
|
||||||
test('getPointerInputMode detects WebXR hand sources', () => {
|
test('getPointerInputMode ignores WebXR hand sources', () => {
|
||||||
assert.equal(getPointerInputMode({ hand: {} }), 'hand');
|
assert.equal(getPointerInputMode({ hand: {} }), null);
|
||||||
assert.equal(getPointerInputMode({ profiles: ['generic-hand-tracking'] }), 'hand');
|
assert.equal(getPointerInputMode({ profiles: ['generic-hand-tracking'] }), null);
|
||||||
assert.equal(getPointerInputMode({ profiles: ['Oculus-Hand'] }), 'hand');
|
assert.equal(getPointerInputMode({ profiles: ['Oculus-Hand'] }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getPointerInputMode detects controller sources', () => {
|
test('getPointerInputMode detects controller sources', () => {
|
||||||
@@ -27,20 +26,20 @@ test('getPointerInputMode returns null for unknown or gaze-like sources', () =>
|
|||||||
|
|
||||||
test('rememberPointerInputMode reads input sources from supported event shapes', () => {
|
test('rememberPointerInputMode reads input sources from supported event shapes', () => {
|
||||||
const fromNestedInputSource = {};
|
const fromNestedInputSource = {};
|
||||||
rememberPointerInputMode(fromNestedInputSource, { data: { inputSource: { hand: {} } } }, 'controller');
|
rememberPointerInputMode(fromNestedInputSource, { data: { inputSource: { gamepad: {} } } }, 'controller');
|
||||||
assert.equal(fromNestedInputSource.pointerInputMode, 'hand');
|
assert.equal(fromNestedInputSource.pointerInputMode, 'controller');
|
||||||
|
|
||||||
const fromDirectInputSource = {};
|
const fromDirectInputSource = {};
|
||||||
rememberPointerInputMode(fromDirectInputSource, { inputSource: { gamepad: {} } }, 'hand');
|
rememberPointerInputMode(fromDirectInputSource, { inputSource: { gamepad: {} } }, 'controller');
|
||||||
assert.equal(fromDirectInputSource.pointerInputMode, 'controller');
|
assert.equal(fromDirectInputSource.pointerInputMode, 'controller');
|
||||||
|
|
||||||
const fromDataSource = {};
|
const fromDataSource = {};
|
||||||
rememberPointerInputMode(fromDataSource, { data: { targetRayMode: 'tracked-pointer' } }, 'hand');
|
rememberPointerInputMode(fromDataSource, { data: { targetRayMode: 'tracked-pointer' } }, 'controller');
|
||||||
assert.equal(fromDataSource.pointerInputMode, 'controller');
|
assert.equal(fromDataSource.pointerInputMode, 'controller');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rememberPointerInputMode keeps fallback mode when the event is ambiguous', () => {
|
test('rememberPointerInputMode keeps fallback mode when the event is ambiguous', () => {
|
||||||
const inputSource = { pointerInputMode: 'hand' };
|
const inputSource = { pointerInputMode: 'controller' };
|
||||||
|
|
||||||
rememberPointerInputMode(inputSource, { data: { targetRayMode: 'gaze' } }, 'controller');
|
rememberPointerInputMode(inputSource, { data: { targetRayMode: 'gaze' } }, 'controller');
|
||||||
|
|
||||||
@@ -56,16 +55,9 @@ test('rememberPointerInputMode stores the input source on controller userData',
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
rememberPointerInputMode(inputSource, { data: { inputSource: { hand: {} } } }, 'controller');
|
rememberPointerInputMode(inputSource, { data: { inputSource: { gamepad: {} } } }, 'controller');
|
||||||
|
|
||||||
assert.equal(inputSource.pointerInputMode, 'hand');
|
assert.equal(inputSource.pointerInputMode, 'controller');
|
||||||
assert.equal(inputSource.controller.userData.existing, true);
|
assert.equal(inputSource.controller.userData.existing, true);
|
||||||
assert.equal(inputSource.controller.userData.vrwpInputSource, inputSource);
|
assert.equal(inputSource.controller.userData.vrwpInputSource, inputSource);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shouldUseHandPointer only enables the hand ray for remembered hand mode', () => {
|
|
||||||
assert.equal(shouldUseHandPointer({ pointerInputMode: 'hand' }), true);
|
|
||||||
assert.equal(shouldUseHandPointer({ pointerInputMode: 'controller' }), false);
|
|
||||||
assert.equal(shouldUseHandPointer({}), false);
|
|
||||||
assert.equal(shouldUseHandPointer(undefined), false);
|
|
||||||
});
|
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
@@ -119,6 +119,52 @@ test('MediaController toggles loop playback state', () => {
|
|||||||
assert.equal(video.loop, 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', () => {
|
test('MediaController resets video and play button to poster state', () => {
|
||||||
const playButton = { classList: createClassList(), disabled: true };
|
const playButton = { classList: createClassList(), disabled: true };
|
||||||
playButton.classList.add('hidden');
|
playButton.classList.add('hidden');
|
||||||
|
|||||||
@@ -51,6 +51,71 @@
|
|||||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.45));
|
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) {
|
@media (max-width: 600px) {
|
||||||
.vrwp-play-button {
|
.vrwp-play-button {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
|
|||||||
Reference in New Issue
Block a user