1
0

Loop and other fixes
All checks were successful
Test / test (push) Successful in 9m37s

This commit is contained in:
Aiden
2026-06-10 16:17:08 +10:00
parent 857c9ac980
commit 707cad3719
13 changed files with 342 additions and 51 deletions

View File

@@ -64,6 +64,7 @@ When the page loads, the media is embedded normally with an entry button over it
- 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.
- Outside WebXR, both modes render only the left half of the SBS media so viewers do not see the raw double image. - Outside WebXR, both modes render only the left half of the SBS media so viewers do not see the raw double image.
- Video controls include a loop toggle for indefinite replay.
- Static images show only applicable controls; playback, seek, and mute controls are video-only. - 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 hand/controller overlays appear after controller interaction, then fade away after a short idle period so they do not distract from viewing.

View File

@@ -82,9 +82,12 @@ export function create2DControlPanel(): HTMLDivElement {
nav.appendChild(forwardBtn); nav.appendChild(forwardBtn);
const muteBtn = createControlButton('vrwp-mute', 'Toggle mute', 'volume-2'); const muteBtn = createControlButton('vrwp-mute', 'Toggle mute', 'volume-2');
const loopBtn = createControlButton('vrwp-loop', 'Loop video', 'repeat');
loopBtn.setAttribute('aria-pressed', 'false');
controls.appendChild(fullscreenBtn); controls.appendChild(fullscreenBtn);
controls.appendChild(nav); controls.appendChild(nav);
controls.appendChild(loopBtn);
controls.appendChild(muteBtn); controls.appendChild(muteBtn);
panel.appendChild(status); panel.appendChild(status);

View File

@@ -8,6 +8,7 @@ export type LucideIconName =
| 'chevron-right' | 'chevron-right'
| 'rotate-ccw' | 'rotate-ccw'
| 'rotate-cw' | 'rotate-cw'
| 'repeat'
| 'volume-2' | 'volume-2'
| 'volume-x' | 'volume-x'
| 'log-out'; | 'log-out';
@@ -53,6 +54,12 @@ const ICONS: Record<LucideIconName, readonly IconNode[]> = {
['path', { d: 'M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8' }], ['path', { d: 'M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8' }],
['path', { d: 'M21 3v5h-5' }] ['path', { d: 'M21 3v5h-5' }]
], ],
repeat: [
['path', { d: 'm17 2 4 4-4 4' }],
['path', { d: 'M3 11v-1a4 4 0 0 1 4-4h14' }],
['path', { d: 'm7 22-4-4 4-4' }],
['path', { d: 'M21 13v1a4 4 0 0 1-4 4H3' }]
],
'volume-2': [ 'volume-2': [
['path', { d: 'M11 4.702a1 1 0 0 0-1.664-.747L5.5 7.5H4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h1.5l3.836 3.545A1 1 0 0 0 11 19.298z' }], ['path', { d: 'M11 4.702a1 1 0 0 0-1.664-.747L5.5 7.5H4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h1.5l3.836 3.545A1 1 0 0 0 11 19.298z' }],
['path', { d: 'M16 9a5 5 0 0 1 0 6' }], ['path', { d: 'M16 9a5 5 0 0 1 0 6' }],

View File

@@ -3,11 +3,13 @@ import { formatTime } from '../utils/time.js';
import type { MediaCapabilities } from '../media/media-adapter.js'; import type { MediaCapabilities } from '../media/media-adapter.js';
type TwoDControlPanelCallbacks = { type TwoDControlPanelCallbacks = {
getIsLooping: () => boolean;
onForward: () => void; onForward: () => void;
onMute: () => void; onMute: () => void;
onPlayPause: () => void; onPlayPause: () => void;
onRewind: () => void; onRewind: () => void;
onSeek: (progress: number) => void; onSeek: (progress: number) => void;
onToggleLoop: () => boolean;
}; };
type TwoDControlPanelOptions = { type TwoDControlPanelOptions = {
@@ -34,6 +36,7 @@ export class TwoDControlPanel {
private totalTimeDisplay: HTMLElement | null; private totalTimeDisplay: HTMLElement | null;
private backButton: HTMLButtonElement | null; private backButton: HTMLButtonElement | null;
private forwardButton: HTMLButtonElement | null; private forwardButton: HTMLButtonElement | null;
private loopButton: HTMLButtonElement | null;
private playButton: HTMLButtonElement | null; private playButton: HTMLButtonElement | null;
private muteButton: HTMLButtonElement | null; private muteButton: HTMLButtonElement | null;
private navControls: HTMLElement | null; private navControls: HTMLElement | null;
@@ -54,6 +57,7 @@ export class TwoDControlPanel {
this.playedBar = playerContainer.querySelector('.vrwp-played'); this.playedBar = playerContainer.querySelector('.vrwp-played');
this.backButton = playerContainer.querySelector('.vrwp-back'); this.backButton = playerContainer.querySelector('.vrwp-back');
this.forwardButton = playerContainer.querySelector('.vrwp-forward'); this.forwardButton = playerContainer.querySelector('.vrwp-forward');
this.loopButton = playerContainer.querySelector('.vrwp-loop');
this.playButton = playerContainer.querySelector('.vrwp-play-toggle'); this.playButton = playerContainer.querySelector('.vrwp-play-toggle');
this.muteButton = playerContainer.querySelector('.vrwp-mute'); this.muteButton = playerContainer.querySelector('.vrwp-mute');
this.navControls = playerContainer.querySelector('.vrwp-nav'); this.navControls = playerContainer.querySelector('.vrwp-nav');
@@ -69,6 +73,7 @@ export class TwoDControlPanel {
this.applyCapabilities(mediaCapabilities); this.applyCapabilities(mediaCapabilities);
this.bindControls(playerContainer, mediaCapabilities); this.bindControls(playerContainer, mediaCapabilities);
this.updateLoopButton(this.callbacks.getIsLooping());
} }
show(): void { show(): void {
@@ -138,6 +143,14 @@ export class TwoDControlPanel {
} }
} }
updateLoopButton(isLooping: boolean): void {
if (!this.loopButton) return;
this.loopButton.classList.toggle('active', isLooping);
this.loopButton.setAttribute('aria-pressed', String(isLooping));
this.loopButton.setAttribute('aria-label', isLooping ? 'Disable video loop' : 'Loop video');
}
updateTime(currentTime: number, duration: number): void { updateTime(currentTime: number, duration: number): void {
if (!this.getIsActive()) return; if (!this.getIsActive()) return;
@@ -168,6 +181,10 @@ export class TwoDControlPanel {
this.playButton.hidden = true; this.playButton.hidden = true;
} }
if (!mediaCapabilities.playback && this.loopButton) {
this.loopButton.hidden = true;
}
if (mediaCapabilities.carousel) { if (mediaCapabilities.carousel) {
this.configureCarouselNavigation(); this.configureCarouselNavigation();
} }
@@ -199,6 +216,11 @@ export class TwoDControlPanel {
this.callbacks.onPlayPause(); this.callbacks.onPlayPause();
this.show(); this.show();
}); });
this.loopButton?.addEventListener('click', () => {
this.updateLoopButton(this.callbacks.onToggleLoop());
this.show();
});
} }
if (mediaCapabilities.audio) { if (mediaCapabilities.audio) {

View File

@@ -6,11 +6,10 @@ type MediaControllerOptions = {
}; };
type HandleMediaEndedOptions = { type HandleMediaEndedOptions = {
cleanupFailedVrExit: () => void;
exitVr: () => Promise<void>;
isIn2DMode: () => boolean; isIn2DMode: () => boolean;
isInVr: () => boolean; isInVr: () => boolean;
on2DEnded: () => void; on2DEnded: () => void;
onVrEnded: () => void;
resetToOriginalState: () => void; resetToOriginalState: () => void;
}; };
@@ -40,20 +39,16 @@ export class MediaController {
} }
handleEnded({ handleEnded({
cleanupFailedVrExit,
exitVr,
isIn2DMode, isIn2DMode,
isInVr, isInVr,
on2DEnded, on2DEnded,
onVrEnded,
resetToOriginalState resetToOriginalState
}: HandleMediaEndedOptions): void { }: HandleMediaEndedOptions): void {
this.pauseIfPlaying(); this.pauseIfPlaying();
if (isInVr()) { if (isInVr()) {
exitVr().catch((err) => { onVrEnded();
console.error('Error during automatic VR exit on video end:', err);
cleanupFailedVrExit();
});
return; return;
} }
@@ -69,6 +64,10 @@ export class MediaController {
this.playButton?.classList.add('hidden'); this.playButton?.classList.add('hidden');
} }
isLooping(): boolean {
return this.video.loop;
}
pauseIfPlaying(): void { pauseIfPlaying(): void {
if (!this.video.paused) { if (!this.video.paused) {
this.video.pause(); this.video.pause();
@@ -105,11 +104,16 @@ export class MediaController {
this.video.muted = !this.video.muted; this.video.muted = !this.video.muted;
} }
toggleLoop(): boolean {
this.video.loop = !this.video.loop;
return this.video.loop;
}
togglePlayPause(): void { togglePlayPause(): void {
if (!this.video.currentSrc) return; if (!this.video.currentSrc) return;
if (this.video.paused || this.video.ended) { if (this.video.paused || this.video.ended) {
if (this.video.ended && this.is2DModeActive()) { if (this.video.ended) {
this.video.currentTime = 0; this.video.currentTime = 0;
} }

View File

@@ -11,10 +11,12 @@ import type { MediaCapabilities } from '../media/media-adapter.js';
type TwoDModeCallbacks = { type TwoDModeCallbacks = {
createMediaTexture: () => any; createMediaTexture: () => any;
forward: () => void; forward: () => void;
getIsLooping: () => boolean;
positionPlaneForPresentation: (isFallback2D?: boolean) => void; positionPlaneForPresentation: (isFallback2D?: boolean) => void;
rewind: () => void; rewind: () => void;
seekToProgress: (progress: number) => void; seekToProgress: (progress: number) => void;
showActiveContentMesh: () => void; showActiveContentMesh: () => void;
toggleLoop: () => boolean;
toggleMute: () => void; toggleMute: () => void;
togglePlayPause: () => void; togglePlayPause: () => void;
}; };
@@ -72,6 +74,7 @@ export class TwoDMode {
this.controls = new TwoDControlPanel({ this.controls = new TwoDControlPanel({
callbacks: { callbacks: {
getIsLooping: this.callbacks.getIsLooping,
onForward: () => { onForward: () => {
this.callbacks.forward(); this.callbacks.forward();
}, },
@@ -84,7 +87,8 @@ export class TwoDMode {
}, },
onSeek: (progress) => { onSeek: (progress) => {
this.callbacks.seekToProgress(progress); this.callbacks.seekToProgress(progress);
} },
onToggleLoop: this.callbacks.toggleLoop
}, },
mediaCapabilities: this.mediaCapabilities, mediaCapabilities: this.mediaCapabilities,
fullscreenTarget: this.fullscreenTarget, fullscreenTarget: this.fullscreenTarget,

View File

@@ -26,6 +26,7 @@ import { bindVideoEvents } from './media/video-events.js';
import { import {
createVrControlPanel, createVrControlPanel,
type VrControlPanel, type VrControlPanel,
updateVrLoopButtonIcon,
updateVrPlayPauseButtonIcon, updateVrPlayPauseButtonIcon,
updateVrSeekBarAppearance, updateVrSeekBarAppearance,
updateVrVolumeButtonIcon updateVrVolumeButtonIcon
@@ -227,10 +228,12 @@ function init() {
callbacks: { callbacks: {
createMediaTexture, createMediaTexture,
forward: navigateForward, forward: navigateForward,
getIsLooping: () => mediaController?.isLooping() ?? false,
positionPlaneForPresentation, positionPlaneForPresentation,
rewind: navigateBackward, rewind: navigateBackward,
seekToProgress: (progress) => mediaController?.seekToProgress(progress), seekToProgress: (progress) => mediaController?.seekToProgress(progress),
showActiveContentMesh, showActiveContentMesh,
toggleLoop,
toggleMute: () => mediaController?.toggleMute(), toggleMute: () => mediaController?.toggleMute(),
togglePlayPause: () => mediaController?.togglePlayPause() togglePlayPause: () => mediaController?.togglePlayPause()
}, },
@@ -304,6 +307,10 @@ function updateVRPlayPauseButtonIcon() {
updateVrPlayPauseButtonIcon(vrPanel, video.paused || video.ended); updateVrPlayPauseButtonIcon(vrPanel, video.paused || video.ended);
} }
function updateVRLoopButtonIcon() {
updateVrLoopButtonIcon(vrPanel, mediaController?.isLooping() ?? false);
}
function updateVRVolumeButtonIcon() { function updateVRVolumeButtonIcon() {
if (!video) { if (!video) {
return; return;
@@ -326,6 +333,10 @@ function showPanel() {
vrPanelVisibility.show(); vrPanelVisibility.show();
} }
function showPanelPersistent() {
vrPanelVisibility.showPersistent();
}
function hidePanel() { function hidePanel() {
vrPanelVisibility.hide(); vrPanelVisibility.hide();
} }
@@ -369,10 +380,22 @@ function update2DMuteButton() {
twoDMode?.updateMuteButton(); twoDMode?.updateMuteButton();
} }
function toggleLoop() {
const isLooping = mediaController?.toggleLoop() ?? false;
updateVRLoopButtonIcon();
return isLooping;
}
function handle2DVideoEnd() { function handle2DVideoEnd() {
twoDMode?.handleVideoEnd(); twoDMode?.handleVideoEnd();
} }
function handleVrVideoEnd() {
updateVRPlayPauseButtonIcon();
updateSeekBarAppearance();
showPanelPersistent();
}
function resetToOriginalState() { function resetToOriginalState() {
if (mediaController) { if (mediaController) {
mediaController.resetToOriginalState(); mediaController.resetToOriginalState();
@@ -394,28 +417,22 @@ function onVideoEnded() {
} }
mediaController.handleEnded({ mediaController.handleEnded({
cleanupFailedVrExit,
exitVr: actualSessionToggle,
isIn2DMode: is2DModeActive, isIn2DMode: is2DModeActive,
isInVr: () => Boolean(xrSession && renderer && renderer.xr.isPresenting), isInVr: () => Boolean(xrSession && renderer && renderer.xr.isPresenting),
on2DEnded: handle2DVideoEnd, on2DEnded: handle2DVideoEnd,
onVrEnded: handleVrVideoEnd,
resetToOriginalState resetToOriginalState
}); });
} }
function cleanupFailedVrExit() {
if (xrSession) {
const sessionToClean = xrSession;
xrSession = null;
sessionToClean.removeEventListener('end', onVRSessionEnd);
sessionToClean.end().catch(e => {}).finally(() => onVRSessionEnd({session: sessionToClean}));
} else {
onVRSessionEnd({session: null});
}
}
function onSelectStartVR(event) { function onSelectStartVR(event) {
handleVrControllerSelect(event, { handleVrControllerSelect(event, {
beginSeekDrag: (controller) => {
xrInputRig?.beginSeekDrag(controller, vrPanel, (progress) => {
mediaController?.seekToProgress(progress);
updateSeekBarAppearance();
});
},
exitVr: () => { exitVr: () => {
if (xrSession) actualSessionToggle(); if (xrSession) actualSessionToggle();
}, },
@@ -436,6 +453,7 @@ function onSelectStartVR(event) {
toggleMute: () => { toggleMute: () => {
mediaController?.toggleMute(); mediaController?.toggleMute();
}, },
toggleLoop,
togglePlayPause: () => { togglePlayPause: () => {
mediaController?.togglePlayPause(); mediaController?.togglePlayPause();
}, },
@@ -519,6 +537,7 @@ async function actualSessionToggle() {
showActiveContentMesh(); showActiveContentMesh();
updateVRPlayPauseButtonIcon(); updateVRPlayPauseButtonIcon();
updateVRLoopButtonIcon();
updateVRVolumeButtonIcon(); updateVRVolumeButtonIcon();
if (vrControlPanel) { if (vrControlPanel) {
vrPanelVisibility.hideImmediately(); vrPanelVisibility.hideImmediately();
@@ -624,10 +643,7 @@ function renderXR(timestamp, frame) {
if (vrPanelVisibility.isFading) { if (vrPanelVisibility.isFading) {
animatePanelFade(timestamp); animatePanelFade(timestamp);
} }
const isInputHoveringVrPanel = xrInputRig?.update(timestamp, getVisibleVrPanelInteractables()) ?? false; xrInputRig?.update(timestamp, getVisibleVrPanelInteractables());
if (isInputHoveringVrPanel) {
vrPanelVisibility.show();
}
if (!frame) { if (!frame) {
console.warn("renderXR called without an XRFrame. Skipping render."); console.warn("renderXR called without an XRFrame. Skipping render.");

View File

@@ -16,6 +16,10 @@ export type VrControlPanel = {
forwardButtonMesh?: any; forwardButtonMesh?: any;
group: any; group: any;
interactables: any[]; interactables: any[];
loopButtonCanvas?: HTMLCanvasElement;
loopButtonContext?: CanvasRenderingContext2D | null;
loopButtonMesh?: any;
loopButtonTexture?: any;
playPauseButtonCanvas?: HTMLCanvasElement; playPauseButtonCanvas?: HTMLCanvasElement;
playPauseButtonContext?: CanvasRenderingContext2D | null; playPauseButtonContext?: CanvasRenderingContext2D | null;
playPauseButtonMesh?: any; playPauseButtonMesh?: any;
@@ -51,6 +55,10 @@ const FIGMA_FORWARD_BUTTON_SIZE_PX = 44;
const FIGMA_FORWARD_BUTTON_X_PX = 281; const FIGMA_FORWARD_BUTTON_X_PX = 281;
const FIGMA_FORWARD_BUTTON_Y_PX = 90; const FIGMA_FORWARD_BUTTON_Y_PX = 90;
const FIGMA_LOOP_BUTTON_SIZE_PX = 44;
const FIGMA_LOOP_BUTTON_X_PX = 352;
const FIGMA_LOOP_BUTTON_Y_PX = 90;
const FIGMA_EXIT_BUTTON_SIZE_PX = 44; const FIGMA_EXIT_BUTTON_SIZE_PX = 44;
const FIGMA_EXIT_BUTTON_X_PX = 42; const FIGMA_EXIT_BUTTON_X_PX = 42;
const FIGMA_EXIT_BUTTON_Y_PX = 90; const FIGMA_EXIT_BUTTON_Y_PX = 90;
@@ -66,7 +74,7 @@ const WORLD_SEEK_BAR_WIDTH = FIGMA_SEEK_BAR_WIDTH_PX * SCALE_FACTOR;
const WORLD_SEEK_BAR_TRACK_HEIGHT = FIGMA_SEEK_BAR_HEIGHT_PX * SCALE_FACTOR; const WORLD_SEEK_BAR_TRACK_HEIGHT = FIGMA_SEEK_BAR_HEIGHT_PX * SCALE_FACTOR;
const WORLD_SEEK_BAR_PROGRESS_HEIGHT = (FIGMA_SEEK_BAR_HEIGHT_PX - 1) * SCALE_FACTOR; const WORLD_SEEK_BAR_PROGRESS_HEIGHT = (FIGMA_SEEK_BAR_HEIGHT_PX - 1) * SCALE_FACTOR;
const WORLD_SEEK_BAR_Y_OFFSET = FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX * SCALE_FACTOR; const WORLD_SEEK_BAR_Y_OFFSET = FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX * SCALE_FACTOR;
const WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER = 5; const WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER = 12;
const PANEL_TEXTURE_WIDTH = 1024; const PANEL_TEXTURE_WIDTH = 1024;
const PANEL_TEXTURE_HEIGHT = Math.round(PANEL_TEXTURE_WIDTH * (FIGMA_PANEL_HEIGHT_PX / FIGMA_PANEL_WIDTH_PX)); const PANEL_TEXTURE_HEIGHT = Math.round(PANEL_TEXTURE_WIDTH * (FIGMA_PANEL_HEIGHT_PX / FIGMA_PANEL_WIDTH_PX));
@@ -139,6 +147,10 @@ export function createVrControlPanel(
let playPauseButtonContext; let playPauseButtonContext;
let playPauseButtonTexture; let playPauseButtonTexture;
let playPauseButtonMesh; let playPauseButtonMesh;
let loopButtonCanvas;
let loopButtonContext;
let loopButtonTexture;
let loopButtonMesh;
let rewindButtonMesh; let rewindButtonMesh;
let forwardButtonMesh; let forwardButtonMesh;
if (mediaCapabilities.playback) { if (mediaCapabilities.playback) {
@@ -158,6 +170,23 @@ export function createVrControlPanel(
group.add(playPauseButtonMesh); group.add(playPauseButtonMesh);
interactables.push(playPauseButtonMesh); interactables.push(playPauseButtonMesh);
loopButtonCanvas = document.createElement('canvas');
loopButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
loopButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
loopButtonContext = loopButtonCanvas.getContext('2d');
loopButtonTexture = new THREE.CanvasTexture(loopButtonCanvas);
loopButtonTexture.minFilter = THREE.LinearFilter;
drawVrLoopButtonIcon(loopButtonContext, loopButtonCanvas, false);
loopButtonMesh = createButtonMesh({
centerX: FIGMA_LOOP_BUTTON_X_PX,
centerY: FIGMA_LOOP_BUTTON_Y_PX,
name: 'vrLoopButton',
size: FIGMA_LOOP_BUTTON_SIZE_PX,
texture: loopButtonTexture
});
group.add(loopButtonMesh);
interactables.push(loopButtonMesh);
} }
if (mediaCapabilities.navigation) { if (mediaCapabilities.navigation) {
@@ -225,6 +254,10 @@ export function createVrControlPanel(
forwardButtonMesh, forwardButtonMesh,
group, group,
interactables, interactables,
loopButtonCanvas,
loopButtonContext,
loopButtonMesh,
loopButtonTexture,
playPauseButtonCanvas, playPauseButtonCanvas,
playPauseButtonContext, playPauseButtonContext,
playPauseButtonMesh, playPauseButtonMesh,
@@ -251,6 +284,13 @@ export function updateVrPlayPauseButtonIcon(panel: VrControlPanel | undefined, i
panel.playPauseButtonTexture.needsUpdate = true; panel.playPauseButtonTexture.needsUpdate = true;
} }
export function updateVrLoopButtonIcon(panel: VrControlPanel | undefined, isLooping: boolean): void {
if (!panel?.loopButtonContext || !panel.loopButtonTexture) return;
drawVrLoopButtonIcon(panel.loopButtonContext, panel.loopButtonCanvas, isLooping);
panel.loopButtonTexture.needsUpdate = true;
}
export function updateVrVolumeButtonIcon(panel: VrControlPanel | undefined, isMuted: boolean): void { export function updateVrVolumeButtonIcon(panel: VrControlPanel | undefined, isMuted: boolean): void {
if (!panel?.volumeButtonContext || !panel.volumeButtonTexture) return; if (!panel?.volumeButtonContext || !panel.volumeButtonTexture) return;
@@ -262,6 +302,22 @@ export function updateVrVolumeButtonIcon(panel: VrControlPanel | undefined, isMu
panel.volumeButtonTexture.needsUpdate = true; panel.volumeButtonTexture.needsUpdate = true;
} }
function drawVrLoopButtonIcon(
ctx: CanvasRenderingContext2D | null | undefined,
canvas: HTMLCanvasElement | undefined,
isLooping: boolean
): void {
if (!ctx || !canvas) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (isLooping) {
drawRoundedRect(ctx, 10, 10, canvas.width - 20, canvas.height - 20, 28, 'rgba(125, 211, 252, 0.24)', false);
}
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
drawLucideIcon(ctx, 'repeat', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, isLooping ? '#7dd3fc' : '#ffffff', 2);
}
export function updateVrSeekBarAppearance(panel: VrControlPanel | undefined, progress: number | null): void { export function updateVrSeekBarAppearance(panel: VrControlPanel | undefined, progress: number | null): void {
if (!panel?.seekBarProgressMesh) return; if (!panel?.seekBarProgressMesh) return;

View File

@@ -16,6 +16,7 @@ import {
} from './hand-aim.js'; } from './hand-aim.js';
type VrControllerSelectionOptions = { type VrControllerSelectionOptions = {
beginSeekDrag?: (controller: any) => void;
exitVr: () => void; exitVr: () => void;
forward: () => void; forward: () => void;
hidePanel: () => void; hidePanel: () => void;
@@ -24,6 +25,7 @@ type VrControllerSelectionOptions = {
rewind: () => void; rewind: () => void;
seek: (progress: number) => void; seek: (progress: number) => void;
showPanel: () => void; showPanel: () => void;
toggleLoop: () => void;
toggleMute: () => void; toggleMute: () => void;
togglePlayPause: () => void; togglePlayPause: () => void;
uiElements: any[]; uiElements: any[];
@@ -31,6 +33,7 @@ type VrControllerSelectionOptions = {
}; };
type VrInputRig = { type VrInputRig = {
beginSeekDrag: (controller: any, panel: VrControlPanel | undefined, onSeek: (progress: number) => void) => void;
hideOverlays: () => void; hideOverlays: () => void;
raycaster: any; raycaster: any;
showOverlays: (timestamp?: number) => void; showOverlays: (timestamp?: number) => void;
@@ -42,19 +45,29 @@ type AimRay = {
origin: any; origin: any;
}; };
type ActiveSeekDrag = {
inputSource: VrInputSource;
onSeek: (progress: number) => void;
panel: VrControlPanel;
};
type HandPointerOverlay = { type HandPointerOverlay = {
fallbackPointerOverlay: any; fallbackPointerOverlay: any;
hand: any; hand: any;
handAimLatch: PalmAimLatch; handAimLatch: PalmAimLatch;
inputSource: VrInputSource;
pointerOverlay: any; pointerOverlay: any;
}; };
type PointerInputMode = 'controller' | 'hand';
type VrInputSource = { type VrInputSource = {
controller: any; controller: any;
controllerPointerOverlay: any; controllerPointerOverlay: any;
hand?: any; hand?: any;
handAimLatch?: PalmAimLatch; handAimLatch?: PalmAimLatch;
handPointerOverlay?: any; handPointerOverlay?: any;
pointerInputMode: PointerInputMode;
}; };
type VrOverlayVisibilityOptions = { type VrOverlayVisibilityOptions = {
@@ -108,23 +121,41 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
const hoverRaycaster = new THREE.Raycaster(); const hoverRaycaster = new THREE.Raycaster();
hoverRaycaster.near = 0.1; hoverRaycaster.near = 0.1;
hoverRaycaster.far = POINTER_LENGTH; hoverRaycaster.far = POINTER_LENGTH;
const dragRaycaster = new THREE.Raycaster();
dragRaycaster.near = 0.1;
dragRaycaster.far = POINTER_LENGTH;
let activeSeekDrag: ActiveSeekDrag | null = null;
for (let index = 0; index < 2; index += 1) { for (let index = 0; index < 2; index += 1) {
const controller = renderer.xr.getController(index); const controller = renderer.xr.getController(index);
const controllerPointerOverlay = createPointerOverlay(index, overlayVisibility); const controllerPointerOverlay = createPointerOverlay(index, overlayVisibility);
const inputSource: VrInputSource = { const inputSource: VrInputSource = {
controller, controller,
controllerPointerOverlay controllerPointerOverlay,
pointerInputMode: 'controller'
};
controller.userData = {
...controller.userData,
vrwpInputSource: inputSource
}; };
inputSources.push(inputSource); inputSources.push(inputSource);
controller.addEventListener('connected', (event: any) => {
rememberPointerInputMode(inputSource, event, 'controller');
});
controller.addEventListener('selectstart', (event: any) => { controller.addEventListener('selectstart', (event: any) => {
const timestamp = getEventTimestamp(event); const timestamp = getEventTimestamp(event);
beginPalmAimSelection(controller.userData?.vrwpHandAimLatch, timestamp); rememberPointerInputMode(inputSource, event, inputSource.pointerInputMode);
if (shouldUseHandPointer(inputSource)) {
beginPalmAimSelection(controller.userData?.vrwpHandAimLatch, timestamp);
}
overlayVisibility.show(timestamp); overlayVisibility.show(timestamp);
onSelectStart(event); onSelectStart(event);
}); });
controller.addEventListener('selectend', () => { controller.addEventListener('selectend', () => {
endPalmAimSelection(controller.userData?.vrwpHandAimLatch); endPalmAimSelection(controller.userData?.vrwpHandAimLatch);
if (activeSeekDrag?.inputSource.controller === controller) {
activeSeekDrag = null;
}
}); });
controller.addEventListener('select', () => { controller.addEventListener('select', () => {
endPalmAimSelection(controller.userData?.vrwpHandAimLatch); endPalmAimSelection(controller.userData?.vrwpHandAimLatch);
@@ -135,6 +166,9 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
const grip = renderer.xr.getControllerGrip?.(index); const grip = renderer.xr.getControllerGrip?.(index);
if (grip) { if (grip) {
grip.addEventListener('connected', (event: any) => {
rememberPointerInputMode(inputSource, event, 'controller');
});
bindOverlayActivity(grip, overlayVisibility); bindOverlayActivity(grip, overlayVisibility);
grip.add(createControllerOverlay(index, overlayVisibility)); grip.add(createControllerOverlay(index, overlayVisibility));
scene.add(grip); scene.add(grip);
@@ -158,6 +192,7 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
rememberHandedness(hand, { data: hand.inputState }); rememberHandedness(hand, { data: hand.inputState });
createHandOverlay(hand, index, overlayVisibility); createHandOverlay(hand, index, overlayVisibility);
hand.addEventListener?.('connected', (event: any) => { hand.addEventListener?.('connected', (event: any) => {
rememberPointerInputMode(inputSource, event, 'hand');
rememberHandedness(hand, event); rememberHandedness(hand, event);
createHandOverlay(hand, index, overlayVisibility); createHandOverlay(hand, index, overlayVisibility);
overlayVisibility.show(); overlayVisibility.show();
@@ -171,6 +206,7 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
fallbackPointerOverlay: controllerPointerOverlay, fallbackPointerOverlay: controllerPointerOverlay,
hand, hand,
handAimLatch, handAimLatch,
inputSource,
pointerOverlay: handPointerOverlay pointerOverlay: handPointerOverlay
}); });
} }
@@ -179,11 +215,21 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
overlayVisibility.hideImmediately(); overlayVisibility.hideImmediately();
return { return {
beginSeekDrag: (controller: any, panel: VrControlPanel | undefined, onSeek: (progress: number) => void) => {
const inputSource = getInputSourceByController(inputSources, controller);
if (!inputSource || !panel?.seekBarHitAreaMesh) {
activeSeekDrag = null;
return;
}
activeSeekDrag = { inputSource, onSeek, panel };
},
hideOverlays: () => overlayVisibility.hideImmediately(), hideOverlays: () => overlayVisibility.hideImmediately(),
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); updateHandPointerOverlays(handPointerOverlays, timestamp);
updateActiveSeekDrag(activeSeekDrag, dragRaycaster, timestamp);
const isHovering = updateInputPointerIntersections(inputSources, hoverTargets, hoverRaycaster, timestamp); const isHovering = updateInputPointerIntersections(inputSources, hoverTargets, hoverRaycaster, timestamp);
if (isHovering) { if (isHovering) {
overlayVisibility.show(timestamp); overlayVisibility.show(timestamp);
@@ -239,9 +285,16 @@ export function handleVrControllerSelect(event: any, options: VrControllerSelect
return; return;
} }
if (firstIntersected.name === 'vrLoopButton') {
options.toggleLoop();
options.showPanel();
return;
}
if (firstIntersected.name === 'seekBarHitArea') { if (firstIntersected.name === 'seekBarHitArea') {
options.showPanel(); options.showPanel();
options.seek(getSeekProgressFromIntersection(options.vrPanel, intersectionPoint)); options.seek(getSeekProgressFromIntersection(options.vrPanel, intersectionPoint));
options.beginSeekDrag?.(controller);
return; return;
} }
@@ -257,7 +310,9 @@ function togglePanel(options: VrControllerSelectionOptions): void {
} }
function applySelectionRay(controller: any, raycaster: any): void { function applySelectionRay(controller: any, raycaster: any): void {
const handRay = getSelectionHandAimRay(controller) || getHandAimRay(controller.userData?.vrwpHand); const handRay = shouldUseHandPointer(controller.userData?.vrwpInputSource)
? getSelectionHandAimRay(controller) || getHandAimRay(controller.userData?.vrwpHand)
: null;
if (handRay) { if (handRay) {
raycaster.ray.origin.copy(handRay.origin); raycaster.ray.origin.copy(handRay.origin);
raycaster.ray.direction.copy(handRay.direction); raycaster.ray.direction.copy(handRay.direction);
@@ -270,6 +325,10 @@ function applySelectionRay(controller: any, raycaster: any): void {
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix); raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
} }
function getInputSourceByController(inputSources: VrInputSource[], controller: any): VrInputSource | undefined {
return inputSources.find((inputSource) => inputSource.controller === controller);
}
function updateInputPointerIntersections( function updateInputPointerIntersections(
inputSources: VrInputSource[], inputSources: VrInputSource[],
hoverTargets: any[], hoverTargets: any[],
@@ -300,8 +359,39 @@ function updateInputPointerIntersections(
return isHoveringAnyTarget; return isHoveringAnyTarget;
} }
function getInputSourceAimRay(inputSource: VrInputSource, timestamp: number): AimRay | null { function updateActiveSeekDrag(activeSeekDrag: ActiveSeekDrag | null, dragRaycaster: any, timestamp: number): void {
if (inputSource.hand && inputSource.handAimLatch) { if (!activeSeekDrag?.panel.seekBarHitAreaMesh) {
return;
}
const aimRay = getInputSourceAimRay(activeSeekDrag.inputSource, timestamp, { preferLiveHandAim: true });
if (!aimRay) {
return;
}
dragRaycaster.ray.origin.copy(aimRay.origin);
dragRaycaster.ray.direction.copy(aimRay.direction);
const intersections = dragRaycaster.intersectObject(activeSeekDrag.panel.seekBarHitAreaMesh, true);
if (intersections.length === 0) {
return;
}
activeSeekDrag.onSeek(getSeekProgressFromIntersection(activeSeekDrag.panel, intersections[0].point));
}
function getInputSourceAimRay(
inputSource: VrInputSource,
timestamp: number,
{ preferLiveHandAim = false }: { preferLiveHandAim?: boolean } = {}
): AimRay | null {
if (shouldUseHandPointer(inputSource) && inputSource.hand && inputSource.handAimLatch) {
if (preferLiveHandAim) {
const handRay = getHandAimRay(inputSource.hand);
if (handRay) {
return handRay;
}
}
const latchedRay = getPalmAimSelectionRay(inputSource.handAimLatch, timestamp); const latchedRay = getPalmAimSelectionRay(inputSource.handAimLatch, timestamp);
if (latchedRay) { if (latchedRay) {
return toAimRay(latchedRay); return toAimRay(latchedRay);
@@ -381,7 +471,19 @@ function setPointerOverlayLength(pointerOverlay: any, length: number): void {
} }
function updateHandPointerOverlays(handPointerOverlays: HandPointerOverlay[], timestamp: number): void { function updateHandPointerOverlays(handPointerOverlays: HandPointerOverlay[], timestamp: number): void {
handPointerOverlays.forEach(({ fallbackPointerOverlay, hand, handAimLatch, pointerOverlay }) => { 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); const currentHandRay = getHandAimRay(hand);
if (currentHandRay) { if (currentHandRay) {
recordStablePalmAimRay(handAimLatch, toPalmAimRay(currentHandRay), timestamp); recordStablePalmAimRay(handAimLatch, toPalmAimRay(currentHandRay), timestamp);
@@ -408,6 +510,45 @@ function updateHandPointerOverlays(handPointerOverlays: HandPointerOverlay[], ti
}); });
} }
function rememberPointerInputMode(
inputSource: VrInputSource,
event: any,
fallbackMode: PointerInputMode
): void {
const eventInputSource = event?.data?.inputSource || event?.inputSource || event?.data;
const nextMode = getPointerInputMode(eventInputSource) || fallbackMode;
inputSource.pointerInputMode = nextMode;
inputSource.controller.userData = {
...inputSource.controller.userData,
vrwpInputSource: inputSource
};
}
function getPointerInputMode(eventInputSource: any): PointerInputMode | null {
if (!eventInputSource) {
return null;
}
if (eventInputSource.hand) {
return 'hand';
}
if (Array.isArray(eventInputSource.profiles) &&
eventInputSource.profiles.some((profile: string) => profile.toLowerCase().includes('hand'))) {
return 'hand';
}
if (eventInputSource.gamepad || eventInputSource.targetRayMode === 'tracked-pointer') {
return 'controller';
}
return null;
}
function shouldUseHandPointer(inputSource: VrInputSource | undefined): boolean {
return inputSource?.pointerInputMode === 'hand';
}
function getSelectionHandAimRay(controller: any): AimRay | null { function getSelectionHandAimRay(controller: any): AimRay | null {
const latch = controller.userData?.vrwpHandAimLatch || const latch = controller.userData?.vrwpHandAimLatch ||
controller.userData?.vrwpHand?.userData?.vrwpAimLatch; controller.userData?.vrwpHand?.userData?.vrwpAimLatch;
@@ -545,6 +686,9 @@ class VrOverlayVisibility {
this.targetOpacity = 0; this.targetOpacity = 0;
} }
const shouldBeVisible = this.targetOpacity > 0 || this.opacity > 0.001;
this.objects.forEach((object) => this.setObjectVisible(object, shouldBeVisible));
if (this.opacity === this.targetOpacity) { if (this.opacity === this.targetOpacity) {
return; return;
} }

View File

@@ -29,6 +29,14 @@ export class VrPanelVisibility {
} }
show(): void { show(): void {
this.showWithAutoHide(true);
}
showPersistent(): void {
this.showWithAutoHide(false);
}
private showWithAutoHide(shouldAutoHide: boolean): void {
if (this.panel) this.panel.group.visible = true; if (this.panel) this.panel.group.visible = true;
this.clearHideTimeout(); this.clearHideTimeout();
@@ -37,7 +45,9 @@ export class VrPanelVisibility {
this.startFade(); this.startFade();
} }
this.hideTimeout = window.setTimeout(() => this.hide(), AUTO_HIDE_DELAY_MS); if (shouldAutoHide) {
this.hideTimeout = window.setTimeout(() => this.hide(), AUTO_HIDE_DELAY_MS);
}
} }
hide(): void { hide(): void {

View File

@@ -22,7 +22,7 @@
<div class="demo-player-frame" data-vr-web-player data-projection="vr180"> <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"> <video poster="../poster.jpg" title="VR180 3D Video" crossorigin="anonymous" playsinline preload="metadata">
<source src="../media/sbs-video.mp4" type="video/mp4"> <source src="../media/Test.mp4" type="video/mp4">
</video> </video>
</div> </div>
</div> </div>

View File

@@ -20,6 +20,7 @@ function createVideo(overrides = {}) {
currentTime: 20, currentTime: 20,
duration: 120, duration: 120,
ended: false, ended: false,
loop: false,
loadCount: 0, loadCount: 0,
muted: false, muted: false,
pauseCount: 0, pauseCount: 0,
@@ -107,6 +108,17 @@ test('MediaController toggles mute and native controls', () => {
assert.equal(video.controls, true); assert.equal(video.controls, true);
}); });
test('MediaController toggles loop playback state', () => {
const { controller, video } = createController();
assert.equal(controller.isLooping(), false);
assert.equal(controller.toggleLoop(), true);
assert.equal(video.loop, true);
assert.equal(controller.isLooping(), true);
assert.equal(controller.toggleLoop(), false);
assert.equal(video.loop, false);
});
test('MediaController resets video and play button to poster state', () => { 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');
@@ -122,7 +134,7 @@ test('MediaController resets video and play button to poster state', () => {
assert.equal(playButton.disabled, false); assert.equal(playButton.disabled, false);
}); });
test('MediaController restarts ended video before playing in 2D mode', async () => { test('MediaController restarts ended video before playing again', async () => {
let resumed = false; let resumed = false;
const { controller, video } = createController({ const { controller, video } = createController({
is2DModeActive: () => true, is2DModeActive: () => true,
@@ -138,6 +150,15 @@ test('MediaController restarts ended video before playing in 2D mode', async ()
assert.equal(video.currentTime, 0); assert.equal(video.currentTime, 0);
assert.equal(video.playCount, 1); assert.equal(video.playCount, 1);
assert.equal(resumed, true); assert.equal(resumed, true);
const vrVideo = createVideo({ currentTime: 120, ended: true, paused: true });
const { controller: vrController } = createController({ video: vrVideo });
vrController.togglePlayPause();
await Promise.resolve();
assert.equal(vrVideo.currentTime, 0);
assert.equal(vrVideo.playCount, 1);
}); });
test('MediaController pauses when toggling playback while already playing', () => { test('MediaController pauses when toggling playback while already playing', () => {
@@ -151,44 +172,37 @@ test('MediaController pauses when toggling playback while already playing', () =
assert.equal(video.paused, true); assert.equal(video.paused, true);
}); });
test('MediaController dispatches ended behavior for VR, 2D, and idle modes', async () => { test('MediaController dispatches ended behavior for VR, 2D, and idle modes', () => {
const vrCalls = []; const vrCalls = [];
const { controller } = createController({ const { controller } = createController({
video: createVideo({ paused: false }) video: createVideo({ paused: false })
}); });
controller.handleEnded({ controller.handleEnded({
cleanupFailedVrExit: () => vrCalls.push('cleanup'),
exitVr: () => {
vrCalls.push('exit');
return Promise.resolve();
},
isIn2DMode: () => false, isIn2DMode: () => false,
isInVr: () => true, isInVr: () => true,
on2DEnded: () => vrCalls.push('2d'), on2DEnded: () => vrCalls.push('2d'),
onVrEnded: () => vrCalls.push('vr'),
resetToOriginalState: () => vrCalls.push('reset') resetToOriginalState: () => vrCalls.push('reset')
}); });
await Promise.resolve(); assert.deepEqual(vrCalls, ['vr']);
assert.deepEqual(vrCalls, ['exit']);
const twoDCalls = []; const twoDCalls = [];
controller.handleEnded({ controller.handleEnded({
cleanupFailedVrExit: () => twoDCalls.push('cleanup'),
exitVr: () => Promise.resolve(),
isIn2DMode: () => true, isIn2DMode: () => true,
isInVr: () => false, isInVr: () => false,
on2DEnded: () => twoDCalls.push('2d'), on2DEnded: () => twoDCalls.push('2d'),
onVrEnded: () => twoDCalls.push('vr'),
resetToOriginalState: () => twoDCalls.push('reset') resetToOriginalState: () => twoDCalls.push('reset')
}); });
assert.deepEqual(twoDCalls, ['2d']); assert.deepEqual(twoDCalls, ['2d']);
const idleCalls = []; const idleCalls = [];
controller.handleEnded({ controller.handleEnded({
cleanupFailedVrExit: () => idleCalls.push('cleanup'),
exitVr: () => Promise.resolve(),
isIn2DMode: () => false, isIn2DMode: () => false,
isInVr: () => false, isInVr: () => false,
on2DEnded: () => idleCalls.push('2d'), on2DEnded: () => idleCalls.push('2d'),
onVrEnded: () => idleCalls.push('vr'),
resetToOriginalState: () => idleCalls.push('reset') resetToOriginalState: () => idleCalls.push('reset')
}); });
assert.deepEqual(idleCalls, ['reset']); assert.deepEqual(idleCalls, ['reset']);

View File

@@ -133,8 +133,9 @@
.vrwp-controls { .vrwp-controls {
display: grid; display: grid;
grid-template-areas: "full lflex nav rflex mute"; grid-template-areas: "full lflex nav rflex loop mute";
grid-template-columns: 44px 1fr 156px 1fr 44px; grid-template-columns: 44px 1fr 156px 1fr 44px 44px;
column-gap: 8px;
height: 44px; height: 44px;
} }
@@ -161,6 +162,7 @@
} }
.vrwp-fullscreen, .vrwp-fullscreen,
.vrwp-loop,
.vrwp-mute, .vrwp-mute,
.vrwp-back, .vrwp-back,
.vrwp-play-toggle, .vrwp-play-toggle,
@@ -177,6 +179,14 @@
grid-area: mute; grid-area: mute;
} }
.vrwp-loop {
grid-area: loop;
}
.vrwp-loop.active {
color: #7dd3fc;
}
.vrwp-nav { .vrwp-nav {
grid-area: nav; grid-area: nav;
display: grid; display: grid;