From 707cad3719462174d38e340d250b8c9fe5351d60 Mon Sep 17 00:00:00 2001 From: Aiden <68633820+awils27@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:17:08 +1000 Subject: [PATCH] Loop and other fixes --- README.md | 1 + src/vr180player/dom/dom.ts | 3 + src/vr180player/dom/icons.ts | 7 + src/vr180player/dom/two-d-control-panel.ts | 22 +++ src/vr180player/media/media-controller.ts | 22 ++- src/vr180player/modes/two-d-mode.ts | 6 +- src/vr180player/vr180-player.ts | 50 ++++-- src/vr180player/xr/vr-control-panel.ts | 58 ++++++- .../xr/vr-controller-interactions.ts | 156 +++++++++++++++++- src/vr180player/xr/vr-panel-visibility.ts | 12 +- test-pages/test-vr180-3d-video.html | 2 +- tests/media-controller.test.mjs | 40 +++-- vr180player/vr180-player.css | 14 +- 13 files changed, 342 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 91c6218..323b45e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/vr180player/dom/dom.ts b/src/vr180player/dom/dom.ts index fa8407d..3bae91f 100644 --- a/src/vr180player/dom/dom.ts +++ b/src/vr180player/dom/dom.ts @@ -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); diff --git a/src/vr180player/dom/icons.ts b/src/vr180player/dom/icons.ts index f54972f..cb13aa8 100644 --- a/src/vr180player/dom/icons.ts +++ b/src/vr180player/dom/icons.ts @@ -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 = { ['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' }], diff --git a/src/vr180player/dom/two-d-control-panel.ts b/src/vr180player/dom/two-d-control-panel.ts index 61f6370..12ebf22 100644 --- a/src/vr180player/dom/two-d-control-panel.ts +++ b/src/vr180player/dom/two-d-control-panel.ts @@ -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) { diff --git a/src/vr180player/media/media-controller.ts b/src/vr180player/media/media-controller.ts index c6d91c6..44a53a3 100644 --- a/src/vr180player/media/media-controller.ts +++ b/src/vr180player/media/media-controller.ts @@ -6,11 +6,10 @@ type MediaControllerOptions = { }; type HandleMediaEndedOptions = { - cleanupFailedVrExit: () => void; - exitVr: () => Promise; 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; } diff --git a/src/vr180player/modes/two-d-mode.ts b/src/vr180player/modes/two-d-mode.ts index 843d157..dcbdda9 100644 --- a/src/vr180player/modes/two-d-mode.ts +++ b/src/vr180player/modes/two-d-mode.ts @@ -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, diff --git a/src/vr180player/vr180-player.ts b/src/vr180player/vr180-player.ts index d4faf1c..b30e645 100644 --- a/src/vr180player/vr180-player.ts +++ b/src/vr180player/vr180-player.ts @@ -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."); diff --git a/src/vr180player/xr/vr-control-panel.ts b/src/vr180player/xr/vr-control-panel.ts index 128b95e..b23ed7c 100644 --- a/src/vr180player/xr/vr-control-panel.ts +++ b/src/vr180player/xr/vr-control-panel.ts @@ -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; diff --git a/src/vr180player/xr/vr-controller-interactions.ts b/src/vr180player/xr/vr-controller-interactions.ts index cc96718..9db7f1b 100644 --- a/src/vr180player/xr/vr-controller-interactions.ts +++ b/src/vr180player/xr/vr-controller-interactions.ts @@ -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); - beginPalmAimSelection(controller.userData?.vrwpHandAimLatch, timestamp); + 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; } diff --git a/src/vr180player/xr/vr-panel-visibility.ts b/src/vr180player/xr/vr-panel-visibility.ts index 037bb7c..48d1fdf 100644 --- a/src/vr180player/xr/vr-panel-visibility.ts +++ b/src/vr180player/xr/vr-panel-visibility.ts @@ -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,7 +45,9 @@ export class VrPanelVisibility { this.startFade(); } - this.hideTimeout = window.setTimeout(() => this.hide(), AUTO_HIDE_DELAY_MS); + if (shouldAutoHide) { + this.hideTimeout = window.setTimeout(() => this.hide(), AUTO_HIDE_DELAY_MS); + } } hide(): void { diff --git a/test-pages/test-vr180-3d-video.html b/test-pages/test-vr180-3d-video.html index 5bbb9bb..116f0c0 100644 --- a/test-pages/test-vr180-3d-video.html +++ b/test-pages/test-vr180-3d-video.html @@ -22,7 +22,7 @@
diff --git a/tests/media-controller.test.mjs b/tests/media-controller.test.mjs index 6637c04..80c7b77 100644 --- a/tests/media-controller.test.mjs +++ b/tests/media-controller.test.mjs @@ -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']); diff --git a/vr180player/vr180-player.css b/vr180player/vr180-player.css index 771104a..ffd136c 100644 --- a/vr180player/vr180-player.css +++ b/vr180player/vr180-player.css @@ -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;