forked from EXT/VR180-Web-Player
This commit is contained in:
@@ -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, `plane` maps the left and right halves onto the matching eyes of a floating 16:9 plane.
|
||||
- Outside WebXR, both modes render only the left half of the SBS media so viewers do not see the raw double image.
|
||||
- Video controls include a loop toggle for indefinite replay.
|
||||
- Static images show only applicable controls; playback, seek, and mute controls are video-only.
|
||||
- Image carousels replace skip buttons with previous/next image controls, so you can change stills without leaving the immersive session.
|
||||
- Controller pointers and lightweight hand/controller overlays appear after controller interaction, then fade away after a short idle period so they do not distract from viewing.
|
||||
|
||||
@@ -82,9 +82,12 @@ export function create2DControlPanel(): HTMLDivElement {
|
||||
nav.appendChild(forwardBtn);
|
||||
|
||||
const muteBtn = createControlButton('vrwp-mute', 'Toggle mute', 'volume-2');
|
||||
const loopBtn = createControlButton('vrwp-loop', 'Loop video', 'repeat');
|
||||
loopBtn.setAttribute('aria-pressed', 'false');
|
||||
|
||||
controls.appendChild(fullscreenBtn);
|
||||
controls.appendChild(nav);
|
||||
controls.appendChild(loopBtn);
|
||||
controls.appendChild(muteBtn);
|
||||
|
||||
panel.appendChild(status);
|
||||
|
||||
@@ -8,6 +8,7 @@ export type LucideIconName =
|
||||
| 'chevron-right'
|
||||
| 'rotate-ccw'
|
||||
| 'rotate-cw'
|
||||
| 'repeat'
|
||||
| 'volume-2'
|
||||
| 'volume-x'
|
||||
| '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 3v5h-5' }]
|
||||
],
|
||||
repeat: [
|
||||
['path', { d: 'm17 2 4 4-4 4' }],
|
||||
['path', { d: 'M3 11v-1a4 4 0 0 1 4-4h14' }],
|
||||
['path', { d: 'm7 22-4-4 4-4' }],
|
||||
['path', { d: 'M21 13v1a4 4 0 0 1-4 4H3' }]
|
||||
],
|
||||
'volume-2': [
|
||||
['path', { d: 'M11 4.702a1 1 0 0 0-1.664-.747L5.5 7.5H4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h1.5l3.836 3.545A1 1 0 0 0 11 19.298z' }],
|
||||
['path', { d: 'M16 9a5 5 0 0 1 0 6' }],
|
||||
|
||||
@@ -3,11 +3,13 @@ import { formatTime } from '../utils/time.js';
|
||||
import type { MediaCapabilities } from '../media/media-adapter.js';
|
||||
|
||||
type TwoDControlPanelCallbacks = {
|
||||
getIsLooping: () => boolean;
|
||||
onForward: () => void;
|
||||
onMute: () => void;
|
||||
onPlayPause: () => void;
|
||||
onRewind: () => void;
|
||||
onSeek: (progress: number) => void;
|
||||
onToggleLoop: () => boolean;
|
||||
};
|
||||
|
||||
type TwoDControlPanelOptions = {
|
||||
@@ -34,6 +36,7 @@ export class TwoDControlPanel {
|
||||
private totalTimeDisplay: HTMLElement | null;
|
||||
private backButton: HTMLButtonElement | null;
|
||||
private forwardButton: HTMLButtonElement | null;
|
||||
private loopButton: HTMLButtonElement | null;
|
||||
private playButton: HTMLButtonElement | null;
|
||||
private muteButton: HTMLButtonElement | null;
|
||||
private navControls: HTMLElement | null;
|
||||
@@ -54,6 +57,7 @@ export class TwoDControlPanel {
|
||||
this.playedBar = playerContainer.querySelector('.vrwp-played');
|
||||
this.backButton = playerContainer.querySelector('.vrwp-back');
|
||||
this.forwardButton = playerContainer.querySelector('.vrwp-forward');
|
||||
this.loopButton = playerContainer.querySelector('.vrwp-loop');
|
||||
this.playButton = playerContainer.querySelector('.vrwp-play-toggle');
|
||||
this.muteButton = playerContainer.querySelector('.vrwp-mute');
|
||||
this.navControls = playerContainer.querySelector('.vrwp-nav');
|
||||
@@ -69,6 +73,7 @@ export class TwoDControlPanel {
|
||||
|
||||
this.applyCapabilities(mediaCapabilities);
|
||||
this.bindControls(playerContainer, mediaCapabilities);
|
||||
this.updateLoopButton(this.callbacks.getIsLooping());
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!this.getIsActive()) return;
|
||||
|
||||
@@ -168,6 +181,10 @@ export class TwoDControlPanel {
|
||||
this.playButton.hidden = true;
|
||||
}
|
||||
|
||||
if (!mediaCapabilities.playback && this.loopButton) {
|
||||
this.loopButton.hidden = true;
|
||||
}
|
||||
|
||||
if (mediaCapabilities.carousel) {
|
||||
this.configureCarouselNavigation();
|
||||
}
|
||||
@@ -199,6 +216,11 @@ export class TwoDControlPanel {
|
||||
this.callbacks.onPlayPause();
|
||||
this.show();
|
||||
});
|
||||
|
||||
this.loopButton?.addEventListener('click', () => {
|
||||
this.updateLoopButton(this.callbacks.onToggleLoop());
|
||||
this.show();
|
||||
});
|
||||
}
|
||||
|
||||
if (mediaCapabilities.audio) {
|
||||
|
||||
@@ -6,11 +6,10 @@ type MediaControllerOptions = {
|
||||
};
|
||||
|
||||
type HandleMediaEndedOptions = {
|
||||
cleanupFailedVrExit: () => void;
|
||||
exitVr: () => Promise<void>;
|
||||
isIn2DMode: () => boolean;
|
||||
isInVr: () => boolean;
|
||||
on2DEnded: () => void;
|
||||
onVrEnded: () => void;
|
||||
resetToOriginalState: () => void;
|
||||
};
|
||||
|
||||
@@ -40,20 +39,16 @@ export class MediaController {
|
||||
}
|
||||
|
||||
handleEnded({
|
||||
cleanupFailedVrExit,
|
||||
exitVr,
|
||||
isIn2DMode,
|
||||
isInVr,
|
||||
on2DEnded,
|
||||
onVrEnded,
|
||||
resetToOriginalState
|
||||
}: HandleMediaEndedOptions): void {
|
||||
this.pauseIfPlaying();
|
||||
|
||||
if (isInVr()) {
|
||||
exitVr().catch((err) => {
|
||||
console.error('Error during automatic VR exit on video end:', err);
|
||||
cleanupFailedVrExit();
|
||||
});
|
||||
onVrEnded();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,6 +64,10 @@ export class MediaController {
|
||||
this.playButton?.classList.add('hidden');
|
||||
}
|
||||
|
||||
isLooping(): boolean {
|
||||
return this.video.loop;
|
||||
}
|
||||
|
||||
pauseIfPlaying(): void {
|
||||
if (!this.video.paused) {
|
||||
this.video.pause();
|
||||
@@ -105,11 +104,16 @@ export class MediaController {
|
||||
this.video.muted = !this.video.muted;
|
||||
}
|
||||
|
||||
toggleLoop(): boolean {
|
||||
this.video.loop = !this.video.loop;
|
||||
return this.video.loop;
|
||||
}
|
||||
|
||||
togglePlayPause(): void {
|
||||
if (!this.video.currentSrc) return;
|
||||
|
||||
if (this.video.paused || this.video.ended) {
|
||||
if (this.video.ended && this.is2DModeActive()) {
|
||||
if (this.video.ended) {
|
||||
this.video.currentTime = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@ import type { MediaCapabilities } from '../media/media-adapter.js';
|
||||
type TwoDModeCallbacks = {
|
||||
createMediaTexture: () => any;
|
||||
forward: () => void;
|
||||
getIsLooping: () => boolean;
|
||||
positionPlaneForPresentation: (isFallback2D?: boolean) => void;
|
||||
rewind: () => void;
|
||||
seekToProgress: (progress: number) => void;
|
||||
showActiveContentMesh: () => void;
|
||||
toggleLoop: () => boolean;
|
||||
toggleMute: () => void;
|
||||
togglePlayPause: () => void;
|
||||
};
|
||||
@@ -72,6 +74,7 @@ export class TwoDMode {
|
||||
|
||||
this.controls = new TwoDControlPanel({
|
||||
callbacks: {
|
||||
getIsLooping: this.callbacks.getIsLooping,
|
||||
onForward: () => {
|
||||
this.callbacks.forward();
|
||||
},
|
||||
@@ -84,7 +87,8 @@ export class TwoDMode {
|
||||
},
|
||||
onSeek: (progress) => {
|
||||
this.callbacks.seekToProgress(progress);
|
||||
}
|
||||
},
|
||||
onToggleLoop: this.callbacks.toggleLoop
|
||||
},
|
||||
mediaCapabilities: this.mediaCapabilities,
|
||||
fullscreenTarget: this.fullscreenTarget,
|
||||
|
||||
@@ -26,6 +26,7 @@ import { bindVideoEvents } from './media/video-events.js';
|
||||
import {
|
||||
createVrControlPanel,
|
||||
type VrControlPanel,
|
||||
updateVrLoopButtonIcon,
|
||||
updateVrPlayPauseButtonIcon,
|
||||
updateVrSeekBarAppearance,
|
||||
updateVrVolumeButtonIcon
|
||||
@@ -227,10 +228,12 @@ function init() {
|
||||
callbacks: {
|
||||
createMediaTexture,
|
||||
forward: navigateForward,
|
||||
getIsLooping: () => mediaController?.isLooping() ?? false,
|
||||
positionPlaneForPresentation,
|
||||
rewind: navigateBackward,
|
||||
seekToProgress: (progress) => mediaController?.seekToProgress(progress),
|
||||
showActiveContentMesh,
|
||||
toggleLoop,
|
||||
toggleMute: () => mediaController?.toggleMute(),
|
||||
togglePlayPause: () => mediaController?.togglePlayPause()
|
||||
},
|
||||
@@ -304,6 +307,10 @@ function updateVRPlayPauseButtonIcon() {
|
||||
updateVrPlayPauseButtonIcon(vrPanel, video.paused || video.ended);
|
||||
}
|
||||
|
||||
function updateVRLoopButtonIcon() {
|
||||
updateVrLoopButtonIcon(vrPanel, mediaController?.isLooping() ?? false);
|
||||
}
|
||||
|
||||
function updateVRVolumeButtonIcon() {
|
||||
if (!video) {
|
||||
return;
|
||||
@@ -326,6 +333,10 @@ function showPanel() {
|
||||
vrPanelVisibility.show();
|
||||
}
|
||||
|
||||
function showPanelPersistent() {
|
||||
vrPanelVisibility.showPersistent();
|
||||
}
|
||||
|
||||
function hidePanel() {
|
||||
vrPanelVisibility.hide();
|
||||
}
|
||||
@@ -369,10 +380,22 @@ function update2DMuteButton() {
|
||||
twoDMode?.updateMuteButton();
|
||||
}
|
||||
|
||||
function toggleLoop() {
|
||||
const isLooping = mediaController?.toggleLoop() ?? false;
|
||||
updateVRLoopButtonIcon();
|
||||
return isLooping;
|
||||
}
|
||||
|
||||
function handle2DVideoEnd() {
|
||||
twoDMode?.handleVideoEnd();
|
||||
}
|
||||
|
||||
function handleVrVideoEnd() {
|
||||
updateVRPlayPauseButtonIcon();
|
||||
updateSeekBarAppearance();
|
||||
showPanelPersistent();
|
||||
}
|
||||
|
||||
function resetToOriginalState() {
|
||||
if (mediaController) {
|
||||
mediaController.resetToOriginalState();
|
||||
@@ -394,28 +417,22 @@ function onVideoEnded() {
|
||||
}
|
||||
|
||||
mediaController.handleEnded({
|
||||
cleanupFailedVrExit,
|
||||
exitVr: actualSessionToggle,
|
||||
isIn2DMode: is2DModeActive,
|
||||
isInVr: () => Boolean(xrSession && renderer && renderer.xr.isPresenting),
|
||||
on2DEnded: handle2DVideoEnd,
|
||||
onVrEnded: handleVrVideoEnd,
|
||||
resetToOriginalState
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupFailedVrExit() {
|
||||
if (xrSession) {
|
||||
const sessionToClean = xrSession;
|
||||
xrSession = null;
|
||||
sessionToClean.removeEventListener('end', onVRSessionEnd);
|
||||
sessionToClean.end().catch(e => {}).finally(() => onVRSessionEnd({session: sessionToClean}));
|
||||
} else {
|
||||
onVRSessionEnd({session: null});
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectStartVR(event) {
|
||||
handleVrControllerSelect(event, {
|
||||
beginSeekDrag: (controller) => {
|
||||
xrInputRig?.beginSeekDrag(controller, vrPanel, (progress) => {
|
||||
mediaController?.seekToProgress(progress);
|
||||
updateSeekBarAppearance();
|
||||
});
|
||||
},
|
||||
exitVr: () => {
|
||||
if (xrSession) actualSessionToggle();
|
||||
},
|
||||
@@ -436,6 +453,7 @@ function onSelectStartVR(event) {
|
||||
toggleMute: () => {
|
||||
mediaController?.toggleMute();
|
||||
},
|
||||
toggleLoop,
|
||||
togglePlayPause: () => {
|
||||
mediaController?.togglePlayPause();
|
||||
},
|
||||
@@ -519,6 +537,7 @@ async function actualSessionToggle() {
|
||||
showActiveContentMesh();
|
||||
|
||||
updateVRPlayPauseButtonIcon();
|
||||
updateVRLoopButtonIcon();
|
||||
updateVRVolumeButtonIcon();
|
||||
if (vrControlPanel) {
|
||||
vrPanelVisibility.hideImmediately();
|
||||
@@ -624,10 +643,7 @@ function renderXR(timestamp, frame) {
|
||||
if (vrPanelVisibility.isFading) {
|
||||
animatePanelFade(timestamp);
|
||||
}
|
||||
const isInputHoveringVrPanel = xrInputRig?.update(timestamp, getVisibleVrPanelInteractables()) ?? false;
|
||||
if (isInputHoveringVrPanel) {
|
||||
vrPanelVisibility.show();
|
||||
}
|
||||
xrInputRig?.update(timestamp, getVisibleVrPanelInteractables());
|
||||
|
||||
if (!frame) {
|
||||
console.warn("renderXR called without an XRFrame. Skipping render.");
|
||||
|
||||
@@ -16,6 +16,10 @@ export type VrControlPanel = {
|
||||
forwardButtonMesh?: any;
|
||||
group: any;
|
||||
interactables: any[];
|
||||
loopButtonCanvas?: HTMLCanvasElement;
|
||||
loopButtonContext?: CanvasRenderingContext2D | null;
|
||||
loopButtonMesh?: any;
|
||||
loopButtonTexture?: any;
|
||||
playPauseButtonCanvas?: HTMLCanvasElement;
|
||||
playPauseButtonContext?: CanvasRenderingContext2D | null;
|
||||
playPauseButtonMesh?: any;
|
||||
@@ -51,6 +55,10 @@ const FIGMA_FORWARD_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_FORWARD_BUTTON_X_PX = 281;
|
||||
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_X_PX = 42;
|
||||
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_PROGRESS_HEIGHT = (FIGMA_SEEK_BAR_HEIGHT_PX - 1) * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_Y_OFFSET = FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER = 5;
|
||||
const WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER = 12;
|
||||
|
||||
const PANEL_TEXTURE_WIDTH = 1024;
|
||||
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 playPauseButtonTexture;
|
||||
let playPauseButtonMesh;
|
||||
let loopButtonCanvas;
|
||||
let loopButtonContext;
|
||||
let loopButtonTexture;
|
||||
let loopButtonMesh;
|
||||
let rewindButtonMesh;
|
||||
let forwardButtonMesh;
|
||||
if (mediaCapabilities.playback) {
|
||||
@@ -158,6 +170,23 @@ export function createVrControlPanel(
|
||||
group.add(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) {
|
||||
@@ -225,6 +254,10 @@ export function createVrControlPanel(
|
||||
forwardButtonMesh,
|
||||
group,
|
||||
interactables,
|
||||
loopButtonCanvas,
|
||||
loopButtonContext,
|
||||
loopButtonMesh,
|
||||
loopButtonTexture,
|
||||
playPauseButtonCanvas,
|
||||
playPauseButtonContext,
|
||||
playPauseButtonMesh,
|
||||
@@ -251,6 +284,13 @@ export function updateVrPlayPauseButtonIcon(panel: VrControlPanel | undefined, i
|
||||
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 {
|
||||
if (!panel?.volumeButtonContext || !panel.volumeButtonTexture) return;
|
||||
|
||||
@@ -262,6 +302,22 @@ export function updateVrVolumeButtonIcon(panel: VrControlPanel | undefined, isMu
|
||||
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 {
|
||||
if (!panel?.seekBarProgressMesh) return;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './hand-aim.js';
|
||||
|
||||
type VrControllerSelectionOptions = {
|
||||
beginSeekDrag?: (controller: any) => void;
|
||||
exitVr: () => void;
|
||||
forward: () => void;
|
||||
hidePanel: () => void;
|
||||
@@ -24,6 +25,7 @@ type VrControllerSelectionOptions = {
|
||||
rewind: () => void;
|
||||
seek: (progress: number) => void;
|
||||
showPanel: () => void;
|
||||
toggleLoop: () => void;
|
||||
toggleMute: () => void;
|
||||
togglePlayPause: () => void;
|
||||
uiElements: any[];
|
||||
@@ -31,6 +33,7 @@ type VrControllerSelectionOptions = {
|
||||
};
|
||||
|
||||
type VrInputRig = {
|
||||
beginSeekDrag: (controller: any, panel: VrControlPanel | undefined, onSeek: (progress: number) => void) => void;
|
||||
hideOverlays: () => void;
|
||||
raycaster: any;
|
||||
showOverlays: (timestamp?: number) => void;
|
||||
@@ -42,19 +45,29 @@ type AimRay = {
|
||||
origin: any;
|
||||
};
|
||||
|
||||
type ActiveSeekDrag = {
|
||||
inputSource: VrInputSource;
|
||||
onSeek: (progress: number) => void;
|
||||
panel: VrControlPanel;
|
||||
};
|
||||
|
||||
type HandPointerOverlay = {
|
||||
fallbackPointerOverlay: any;
|
||||
hand: any;
|
||||
handAimLatch: PalmAimLatch;
|
||||
inputSource: VrInputSource;
|
||||
pointerOverlay: any;
|
||||
};
|
||||
|
||||
type PointerInputMode = 'controller' | 'hand';
|
||||
|
||||
type VrInputSource = {
|
||||
controller: any;
|
||||
controllerPointerOverlay: any;
|
||||
hand?: any;
|
||||
handAimLatch?: PalmAimLatch;
|
||||
handPointerOverlay?: any;
|
||||
pointerInputMode: PointerInputMode;
|
||||
};
|
||||
|
||||
type VrOverlayVisibilityOptions = {
|
||||
@@ -108,23 +121,41 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
|
||||
const hoverRaycaster = new THREE.Raycaster();
|
||||
hoverRaycaster.near = 0.1;
|
||||
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) {
|
||||
const controller = renderer.xr.getController(index);
|
||||
const controllerPointerOverlay = createPointerOverlay(index, overlayVisibility);
|
||||
const inputSource: VrInputSource = {
|
||||
controller,
|
||||
controllerPointerOverlay
|
||||
controllerPointerOverlay,
|
||||
pointerInputMode: 'controller'
|
||||
};
|
||||
controller.userData = {
|
||||
...controller.userData,
|
||||
vrwpInputSource: inputSource
|
||||
};
|
||||
inputSources.push(inputSource);
|
||||
controller.addEventListener('connected', (event: any) => {
|
||||
rememberPointerInputMode(inputSource, event, 'controller');
|
||||
});
|
||||
controller.addEventListener('selectstart', (event: any) => {
|
||||
const timestamp = getEventTimestamp(event);
|
||||
rememberPointerInputMode(inputSource, event, inputSource.pointerInputMode);
|
||||
if (shouldUseHandPointer(inputSource)) {
|
||||
beginPalmAimSelection(controller.userData?.vrwpHandAimLatch, timestamp);
|
||||
}
|
||||
overlayVisibility.show(timestamp);
|
||||
onSelectStart(event);
|
||||
});
|
||||
controller.addEventListener('selectend', () => {
|
||||
endPalmAimSelection(controller.userData?.vrwpHandAimLatch);
|
||||
if (activeSeekDrag?.inputSource.controller === controller) {
|
||||
activeSeekDrag = null;
|
||||
}
|
||||
});
|
||||
controller.addEventListener('select', () => {
|
||||
endPalmAimSelection(controller.userData?.vrwpHandAimLatch);
|
||||
@@ -135,6 +166,9 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
|
||||
|
||||
const grip = renderer.xr.getControllerGrip?.(index);
|
||||
if (grip) {
|
||||
grip.addEventListener('connected', (event: any) => {
|
||||
rememberPointerInputMode(inputSource, event, 'controller');
|
||||
});
|
||||
bindOverlayActivity(grip, overlayVisibility);
|
||||
grip.add(createControllerOverlay(index, overlayVisibility));
|
||||
scene.add(grip);
|
||||
@@ -158,6 +192,7 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
|
||||
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();
|
||||
@@ -171,6 +206,7 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
|
||||
fallbackPointerOverlay: controllerPointerOverlay,
|
||||
hand,
|
||||
handAimLatch,
|
||||
inputSource,
|
||||
pointerOverlay: handPointerOverlay
|
||||
});
|
||||
}
|
||||
@@ -179,11 +215,21 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
|
||||
overlayVisibility.hideImmediately();
|
||||
|
||||
return {
|
||||
beginSeekDrag: (controller: any, panel: VrControlPanel | undefined, onSeek: (progress: number) => void) => {
|
||||
const inputSource = getInputSourceByController(inputSources, controller);
|
||||
if (!inputSource || !panel?.seekBarHitAreaMesh) {
|
||||
activeSeekDrag = null;
|
||||
return;
|
||||
}
|
||||
|
||||
activeSeekDrag = { inputSource, onSeek, panel };
|
||||
},
|
||||
hideOverlays: () => overlayVisibility.hideImmediately(),
|
||||
raycaster,
|
||||
showOverlays: (timestamp?: number) => overlayVisibility.show(timestamp),
|
||||
update: (timestamp: number, hoverTargets: any[] = []) => {
|
||||
updateHandPointerOverlays(handPointerOverlays, timestamp);
|
||||
updateActiveSeekDrag(activeSeekDrag, dragRaycaster, timestamp);
|
||||
const isHovering = updateInputPointerIntersections(inputSources, hoverTargets, hoverRaycaster, timestamp);
|
||||
if (isHovering) {
|
||||
overlayVisibility.show(timestamp);
|
||||
@@ -239,9 +285,16 @@ export function handleVrControllerSelect(event: any, options: VrControllerSelect
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstIntersected.name === 'vrLoopButton') {
|
||||
options.toggleLoop();
|
||||
options.showPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstIntersected.name === 'seekBarHitArea') {
|
||||
options.showPanel();
|
||||
options.seek(getSeekProgressFromIntersection(options.vrPanel, intersectionPoint));
|
||||
options.beginSeekDrag?.(controller);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -257,7 +310,9 @@ function togglePanel(options: VrControllerSelectionOptions): 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) {
|
||||
raycaster.ray.origin.copy(handRay.origin);
|
||||
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);
|
||||
}
|
||||
|
||||
function getInputSourceByController(inputSources: VrInputSource[], controller: any): VrInputSource | undefined {
|
||||
return inputSources.find((inputSource) => inputSource.controller === controller);
|
||||
}
|
||||
|
||||
function updateInputPointerIntersections(
|
||||
inputSources: VrInputSource[],
|
||||
hoverTargets: any[],
|
||||
@@ -300,8 +359,39 @@ function updateInputPointerIntersections(
|
||||
return isHoveringAnyTarget;
|
||||
}
|
||||
|
||||
function getInputSourceAimRay(inputSource: VrInputSource, timestamp: number): AimRay | null {
|
||||
if (inputSource.hand && inputSource.handAimLatch) {
|
||||
function updateActiveSeekDrag(activeSeekDrag: ActiveSeekDrag | null, dragRaycaster: any, timestamp: number): void {
|
||||
if (!activeSeekDrag?.panel.seekBarHitAreaMesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
const aimRay = getInputSourceAimRay(activeSeekDrag.inputSource, timestamp, { preferLiveHandAim: true });
|
||||
if (!aimRay) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragRaycaster.ray.origin.copy(aimRay.origin);
|
||||
dragRaycaster.ray.direction.copy(aimRay.direction);
|
||||
const intersections = dragRaycaster.intersectObject(activeSeekDrag.panel.seekBarHitAreaMesh, true);
|
||||
if (intersections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeSeekDrag.onSeek(getSeekProgressFromIntersection(activeSeekDrag.panel, intersections[0].point));
|
||||
}
|
||||
|
||||
function getInputSourceAimRay(
|
||||
inputSource: VrInputSource,
|
||||
timestamp: number,
|
||||
{ preferLiveHandAim = false }: { preferLiveHandAim?: boolean } = {}
|
||||
): AimRay | null {
|
||||
if (shouldUseHandPointer(inputSource) && inputSource.hand && inputSource.handAimLatch) {
|
||||
if (preferLiveHandAim) {
|
||||
const handRay = getHandAimRay(inputSource.hand);
|
||||
if (handRay) {
|
||||
return handRay;
|
||||
}
|
||||
}
|
||||
|
||||
const latchedRay = getPalmAimSelectionRay(inputSource.handAimLatch, timestamp);
|
||||
if (latchedRay) {
|
||||
return toAimRay(latchedRay);
|
||||
@@ -381,7 +471,19 @@ function setPointerOverlayLength(pointerOverlay: any, length: 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);
|
||||
if (currentHandRay) {
|
||||
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 {
|
||||
const latch = controller.userData?.vrwpHandAimLatch ||
|
||||
controller.userData?.vrwpHand?.userData?.vrwpAimLatch;
|
||||
@@ -545,6 +686,9 @@ class VrOverlayVisibility {
|
||||
this.targetOpacity = 0;
|
||||
}
|
||||
|
||||
const shouldBeVisible = this.targetOpacity > 0 || this.opacity > 0.001;
|
||||
this.objects.forEach((object) => this.setObjectVisible(object, shouldBeVisible));
|
||||
|
||||
if (this.opacity === this.targetOpacity) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ export class VrPanelVisibility {
|
||||
}
|
||||
|
||||
show(): void {
|
||||
this.showWithAutoHide(true);
|
||||
}
|
||||
|
||||
showPersistent(): void {
|
||||
this.showWithAutoHide(false);
|
||||
}
|
||||
|
||||
private showWithAutoHide(shouldAutoHide: boolean): void {
|
||||
if (this.panel) this.panel.group.visible = true;
|
||||
this.clearHideTimeout();
|
||||
|
||||
@@ -37,8 +45,10 @@ export class VrPanelVisibility {
|
||||
this.startFade();
|
||||
}
|
||||
|
||||
if (shouldAutoHide) {
|
||||
this.hideTimeout = window.setTimeout(() => this.hide(), AUTO_HIDE_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.clearHideTimeout();
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<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/sbs-video.mp4" type="video/mp4">
|
||||
<source src="../media/Test.mp4" type="video/mp4">
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ function createVideo(overrides = {}) {
|
||||
currentTime: 20,
|
||||
duration: 120,
|
||||
ended: false,
|
||||
loop: false,
|
||||
loadCount: 0,
|
||||
muted: false,
|
||||
pauseCount: 0,
|
||||
@@ -107,6 +108,17 @@ test('MediaController toggles mute and native controls', () => {
|
||||
assert.equal(video.controls, true);
|
||||
});
|
||||
|
||||
test('MediaController toggles loop playback state', () => {
|
||||
const { controller, video } = createController();
|
||||
|
||||
assert.equal(controller.isLooping(), false);
|
||||
assert.equal(controller.toggleLoop(), true);
|
||||
assert.equal(video.loop, true);
|
||||
assert.equal(controller.isLooping(), true);
|
||||
assert.equal(controller.toggleLoop(), false);
|
||||
assert.equal(video.loop, false);
|
||||
});
|
||||
|
||||
test('MediaController resets video and play button to poster state', () => {
|
||||
const playButton = { classList: createClassList(), disabled: true };
|
||||
playButton.classList.add('hidden');
|
||||
@@ -122,7 +134,7 @@ test('MediaController resets video and play button to poster state', () => {
|
||||
assert.equal(playButton.disabled, false);
|
||||
});
|
||||
|
||||
test('MediaController restarts ended video before playing in 2D mode', async () => {
|
||||
test('MediaController restarts ended video before playing again', async () => {
|
||||
let resumed = false;
|
||||
const { controller, video } = createController({
|
||||
is2DModeActive: () => true,
|
||||
@@ -138,6 +150,15 @@ test('MediaController restarts ended video before playing in 2D mode', async ()
|
||||
assert.equal(video.currentTime, 0);
|
||||
assert.equal(video.playCount, 1);
|
||||
assert.equal(resumed, true);
|
||||
|
||||
const vrVideo = createVideo({ currentTime: 120, ended: true, paused: true });
|
||||
const { controller: vrController } = createController({ video: vrVideo });
|
||||
|
||||
vrController.togglePlayPause();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(vrVideo.currentTime, 0);
|
||||
assert.equal(vrVideo.playCount, 1);
|
||||
});
|
||||
|
||||
test('MediaController pauses when toggling playback while already playing', () => {
|
||||
@@ -151,44 +172,37 @@ test('MediaController pauses when toggling playback while already playing', () =
|
||||
assert.equal(video.paused, true);
|
||||
});
|
||||
|
||||
test('MediaController dispatches ended behavior for VR, 2D, and idle modes', async () => {
|
||||
test('MediaController dispatches ended behavior for VR, 2D, and idle modes', () => {
|
||||
const vrCalls = [];
|
||||
const { controller } = createController({
|
||||
video: createVideo({ paused: false })
|
||||
});
|
||||
|
||||
controller.handleEnded({
|
||||
cleanupFailedVrExit: () => vrCalls.push('cleanup'),
|
||||
exitVr: () => {
|
||||
vrCalls.push('exit');
|
||||
return Promise.resolve();
|
||||
},
|
||||
isIn2DMode: () => false,
|
||||
isInVr: () => true,
|
||||
on2DEnded: () => vrCalls.push('2d'),
|
||||
onVrEnded: () => vrCalls.push('vr'),
|
||||
resetToOriginalState: () => vrCalls.push('reset')
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(vrCalls, ['exit']);
|
||||
assert.deepEqual(vrCalls, ['vr']);
|
||||
|
||||
const twoDCalls = [];
|
||||
controller.handleEnded({
|
||||
cleanupFailedVrExit: () => twoDCalls.push('cleanup'),
|
||||
exitVr: () => Promise.resolve(),
|
||||
isIn2DMode: () => true,
|
||||
isInVr: () => false,
|
||||
on2DEnded: () => twoDCalls.push('2d'),
|
||||
onVrEnded: () => twoDCalls.push('vr'),
|
||||
resetToOriginalState: () => twoDCalls.push('reset')
|
||||
});
|
||||
assert.deepEqual(twoDCalls, ['2d']);
|
||||
|
||||
const idleCalls = [];
|
||||
controller.handleEnded({
|
||||
cleanupFailedVrExit: () => idleCalls.push('cleanup'),
|
||||
exitVr: () => Promise.resolve(),
|
||||
isIn2DMode: () => false,
|
||||
isInVr: () => false,
|
||||
on2DEnded: () => idleCalls.push('2d'),
|
||||
onVrEnded: () => idleCalls.push('vr'),
|
||||
resetToOriginalState: () => idleCalls.push('reset')
|
||||
});
|
||||
assert.deepEqual(idleCalls, ['reset']);
|
||||
|
||||
@@ -133,8 +133,9 @@
|
||||
|
||||
.vrwp-controls {
|
||||
display: grid;
|
||||
grid-template-areas: "full lflex nav rflex mute";
|
||||
grid-template-columns: 44px 1fr 156px 1fr 44px;
|
||||
grid-template-areas: "full lflex nav rflex loop mute";
|
||||
grid-template-columns: 44px 1fr 156px 1fr 44px 44px;
|
||||
column-gap: 8px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
@@ -161,6 +162,7 @@
|
||||
}
|
||||
|
||||
.vrwp-fullscreen,
|
||||
.vrwp-loop,
|
||||
.vrwp-mute,
|
||||
.vrwp-back,
|
||||
.vrwp-play-toggle,
|
||||
@@ -177,6 +179,14 @@
|
||||
grid-area: mute;
|
||||
}
|
||||
|
||||
.vrwp-loop {
|
||||
grid-area: loop;
|
||||
}
|
||||
|
||||
.vrwp-loop.active {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.vrwp-nav {
|
||||
grid-area: nav;
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user