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, `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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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' }],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
rememberPointerInputMode(inputSource, event, inputSource.pointerInputMode);
|
||||||
|
if (shouldUseHandPointer(inputSource)) {
|
||||||
beginPalmAimSelection(controller.userData?.vrwpHandAimLatch, timestamp);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +45,10 @@ export class VrPanelVisibility {
|
|||||||
this.startFade();
|
this.startFade();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldAutoHide) {
|
||||||
this.hideTimeout = window.setTimeout(() => this.hide(), AUTO_HIDE_DELAY_MS);
|
this.hideTimeout = window.setTimeout(() => this.hide(), AUTO_HIDE_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hide(): void {
|
hide(): void {
|
||||||
this.clearHideTimeout();
|
this.clearHideTimeout();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user