forked from EXT/VR180-Web-Player
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
"dev": "npm run build && vite --host 0.0.0.0",
|
"dev": "npm run build && vite --host 0.0.0.0",
|
||||||
"build": "tsc && node scripts/copy-styles.mjs",
|
"build": "tsc && node scripts/copy-styles.mjs",
|
||||||
"check": "tsc --noEmit",
|
"check": "tsc --noEmit",
|
||||||
"test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.test.mjs tests/media-adapter.test.mjs tests/hand-aim.test.mjs tests/input-mode.test.mjs",
|
"test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.test.mjs tests/media-adapter.test.mjs tests/hand-aim.test.mjs tests/input-mode.test.mjs tests/icons.test.mjs tests/control-panel-timing.test.mjs",
|
||||||
"preview": "npm run build && vite preview --host 127.0.0.1"
|
"preview": "npm run build && vite preview --host 127.0.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -160,7 +160,15 @@ function drawIconNode(ctx: CanvasRenderingContext2D, tagName: string, attrs: Ico
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawPoints(ctx: CanvasRenderingContext2D, pointsAttr: string, closePath: boolean): void {
|
function drawPoints(ctx: CanvasRenderingContext2D, pointsAttr: string, closePath: boolean): void {
|
||||||
const points = pointsAttr.trim().split(/\s+/).map((pair) => pair.split(',').map(Number));
|
const coordinates = pointsAttr.trim().split(/[\s,]+/).map(Number);
|
||||||
|
const points = [];
|
||||||
|
for (let index = 0; index < coordinates.length - 1; index += 2) {
|
||||||
|
const x = coordinates[index];
|
||||||
|
const y = coordinates[index + 1];
|
||||||
|
if (Number.isFinite(x) && Number.isFinite(y)) {
|
||||||
|
points.push([x, y]);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (points.length === 0) return;
|
if (points.length === 0) return;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { setLucideIcon } from './icons.js';
|
import { setLucideIcon } from './icons.js';
|
||||||
import { formatTime } from '../utils/time.js';
|
import { formatTime } from '../utils/time.js';
|
||||||
import type { MediaCapabilities } from '../media/media-adapter.js';
|
import type { MediaCapabilities } from '../media/media-adapter.js';
|
||||||
|
import { DEFAULT_2D_CONTROL_AUTO_HIDE_DELAY_MS } from '../utils/control-panel-timing.js';
|
||||||
|
|
||||||
type TwoDControlPanelCallbacks = {
|
type TwoDControlPanelCallbacks = {
|
||||||
getIsLooping: () => boolean;
|
getIsLooping: () => boolean;
|
||||||
@@ -15,17 +16,17 @@ type TwoDControlPanelCallbacks = {
|
|||||||
type TwoDControlPanelOptions = {
|
type TwoDControlPanelOptions = {
|
||||||
callbacks: TwoDControlPanelCallbacks;
|
callbacks: TwoDControlPanelCallbacks;
|
||||||
fullscreenTarget: HTMLElement;
|
fullscreenTarget: HTMLElement;
|
||||||
|
getAutoHideDelayMs?: () => number;
|
||||||
getIsActive: () => boolean;
|
getIsActive: () => boolean;
|
||||||
mediaCapabilities: MediaCapabilities;
|
mediaCapabilities: MediaCapabilities;
|
||||||
playerContainer: HTMLElement;
|
playerContainer: HTMLElement;
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONTROL_PANEL_HIDE_DELAY = 3000;
|
|
||||||
|
|
||||||
export class TwoDControlPanel {
|
export class TwoDControlPanel {
|
||||||
private readonly callbacks: TwoDControlPanelCallbacks;
|
private readonly callbacks: TwoDControlPanelCallbacks;
|
||||||
private readonly fullscreenTarget: HTMLElement;
|
private readonly fullscreenTarget: HTMLElement;
|
||||||
|
private readonly getAutoHideDelayMs: () => number;
|
||||||
private readonly getIsActive: () => boolean;
|
private readonly getIsActive: () => boolean;
|
||||||
private readonly playerContainer: HTMLElement;
|
private readonly playerContainer: HTMLElement;
|
||||||
private controlPanel: HTMLElement | null;
|
private controlPanel: HTMLElement | null;
|
||||||
@@ -42,9 +43,10 @@ export class TwoDControlPanel {
|
|||||||
private navControls: HTMLElement | null;
|
private navControls: HTMLElement | null;
|
||||||
private progressControls: HTMLElement | null;
|
private progressControls: HTMLElement | null;
|
||||||
|
|
||||||
constructor({ callbacks, fullscreenTarget, getIsActive, mediaCapabilities, playerContainer, title }: TwoDControlPanelOptions) {
|
constructor({ callbacks, fullscreenTarget, getAutoHideDelayMs, getIsActive, mediaCapabilities, playerContainer, title }: TwoDControlPanelOptions) {
|
||||||
this.callbacks = callbacks;
|
this.callbacks = callbacks;
|
||||||
this.fullscreenTarget = fullscreenTarget;
|
this.fullscreenTarget = fullscreenTarget;
|
||||||
|
this.getAutoHideDelayMs = getAutoHideDelayMs ?? (() => DEFAULT_2D_CONTROL_AUTO_HIDE_DELAY_MS);
|
||||||
this.getIsActive = getIsActive;
|
this.getIsActive = getIsActive;
|
||||||
this.playerContainer = playerContainer;
|
this.playerContainer = playerContainer;
|
||||||
|
|
||||||
@@ -81,7 +83,7 @@ export class TwoDControlPanel {
|
|||||||
|
|
||||||
this.clearHideTimeout();
|
this.clearHideTimeout();
|
||||||
this.controlPanel.classList.add('visible');
|
this.controlPanel.classList.add('visible');
|
||||||
this.hideTimeout = window.setTimeout(() => this.hide(), CONTROL_PANEL_HIDE_DELAY);
|
this.hideTimeout = window.setTimeout(() => this.hide(), this.getAutoHideDelayMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
showPersistent(): void {
|
showPersistent(): void {
|
||||||
@@ -141,6 +143,8 @@ export class TwoDControlPanel {
|
|||||||
this.playButton.classList.add('playing');
|
this.playButton.classList.add('playing');
|
||||||
setLucideIcon(this.playButton, 'pause');
|
setLucideIcon(this.playButton, 'pause');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.refreshAutoHideIfVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLoopButton(isLooping: boolean): void {
|
updateLoopButton(isLooping: boolean): void {
|
||||||
@@ -248,6 +252,14 @@ export class TwoDControlPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private refreshAutoHideIfVisible(): void {
|
||||||
|
if (!this.controlPanel?.classList.contains('visible')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.show();
|
||||||
|
}
|
||||||
|
|
||||||
private configureCarouselNavigation(): void {
|
private configureCarouselNavigation(): void {
|
||||||
if (this.backButton) {
|
if (this.backButton) {
|
||||||
this.backButton.setAttribute('aria-label', 'Previous image');
|
this.backButton.setAttribute('aria-label', 'Previous image');
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ type HandleMediaEndedOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SKIP_SECONDS = 15;
|
const DEFAULT_SKIP_SECONDS = 15;
|
||||||
|
const SEAMLESS_LOOP_LOOKAHEAD_SECONDS = 0.18;
|
||||||
|
|
||||||
export class MediaController {
|
export class MediaController {
|
||||||
private readonly is2DModeActive: () => boolean;
|
private readonly is2DModeActive: () => boolean;
|
||||||
private readonly on2DPlaybackResume: () => void;
|
private readonly on2DPlaybackResume: () => void;
|
||||||
private readonly playButton?: HTMLButtonElement;
|
private readonly playButton?: HTMLButtonElement;
|
||||||
private readonly video: HTMLVideoElement;
|
private readonly video: HTMLVideoElement;
|
||||||
|
private loopFrameCallbackId: number | undefined;
|
||||||
|
|
||||||
constructor({ is2DModeActive, on2DPlaybackResume, playButton, video }: MediaControllerOptions) {
|
constructor({ is2DModeActive, on2DPlaybackResume, playButton, video }: MediaControllerOptions) {
|
||||||
this.is2DModeActive = is2DModeActive;
|
this.is2DModeActive = is2DModeActive;
|
||||||
@@ -38,6 +40,10 @@ export class MediaController {
|
|||||||
this.video.currentTime = Math.min(this.video.duration, this.video.currentTime + seconds);
|
this.video.currentTime = Math.min(this.video.duration, this.video.currentTime + seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleTimeUpdate(): void {
|
||||||
|
this.loopBeforeEndedIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
handleEnded({
|
handleEnded({
|
||||||
isIn2DMode,
|
isIn2DMode,
|
||||||
isInVr,
|
isInVr,
|
||||||
@@ -72,13 +78,17 @@ export class MediaController {
|
|||||||
if (!this.video.paused) {
|
if (!this.video.paused) {
|
||||||
this.video.pause();
|
this.video.pause();
|
||||||
}
|
}
|
||||||
|
this.syncSeamlessLoopMonitor();
|
||||||
}
|
}
|
||||||
|
|
||||||
play(): Promise<void> {
|
play(): Promise<void> {
|
||||||
return this.video.play();
|
const playPromise = this.video.play();
|
||||||
|
playPromise.then(() => this.syncSeamlessLoopMonitor()).catch(() => {});
|
||||||
|
return playPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetToOriginalState(): void {
|
resetToOriginalState(): void {
|
||||||
|
this.stopSeamlessLoopMonitor();
|
||||||
this.video.pause();
|
this.video.pause();
|
||||||
this.video.currentTime = 0;
|
this.video.currentTime = 0;
|
||||||
this.video.controls = false;
|
this.video.controls = false;
|
||||||
@@ -106,6 +116,7 @@ export class MediaController {
|
|||||||
|
|
||||||
toggleLoop(): boolean {
|
toggleLoop(): boolean {
|
||||||
this.video.loop = !this.video.loop;
|
this.video.loop = !this.video.loop;
|
||||||
|
this.syncSeamlessLoopMonitor();
|
||||||
return this.video.loop;
|
return this.video.loop;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +132,7 @@ export class MediaController {
|
|||||||
const playPromise = this.video.play() as Promise<void> | undefined;
|
const playPromise = this.video.play() as Promise<void> | undefined;
|
||||||
if (playPromise !== undefined) {
|
if (playPromise !== undefined) {
|
||||||
playPromise.then(() => {
|
playPromise.then(() => {
|
||||||
|
this.syncSeamlessLoopMonitor();
|
||||||
if (this.is2DModeActive() && this.video.ended === false) {
|
if (this.is2DModeActive() && this.video.ended === false) {
|
||||||
this.on2DPlaybackResume();
|
this.on2DPlaybackResume();
|
||||||
}
|
}
|
||||||
@@ -133,5 +145,56 @@ export class MediaController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.video.pause();
|
this.video.pause();
|
||||||
|
this.syncSeamlessLoopMonitor();
|
||||||
|
}
|
||||||
|
|
||||||
|
syncSeamlessLoopMonitor(): void {
|
||||||
|
const requestVideoFrameCallback = this.video.requestVideoFrameCallback?.bind(this.video);
|
||||||
|
if (!requestVideoFrameCallback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.video.loop || this.video.paused) {
|
||||||
|
this.stopSeamlessLoopMonitor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.loopFrameCallbackId !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loopFrameCallbackId = requestVideoFrameCallback((now, metadata) => {
|
||||||
|
this.loopFrameCallbackId = undefined;
|
||||||
|
this.loopBeforeEndedIfNeeded(metadata?.mediaTime);
|
||||||
|
this.syncSeamlessLoopMonitor();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopSeamlessLoopMonitor(): void {
|
||||||
|
if (this.loopFrameCallbackId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.video.cancelVideoFrameCallback?.(this.loopFrameCallbackId);
|
||||||
|
this.loopFrameCallbackId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loopBeforeEndedIfNeeded(mediaTime = this.video.currentTime): boolean {
|
||||||
|
if (!this.video.loop || this.video.paused) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFinite(this.video.duration) || this.video.duration <= SEAMLESS_LOOP_LOOKAHEAD_SECONDS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaTime < this.video.duration - SEAMLESS_LOOP_LOOKAHEAD_SECONDS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.video.currentTime = 0;
|
||||||
|
const playPromise = this.video.play() as Promise<void> | undefined;
|
||||||
|
playPromise?.catch((err) => console.error('Error restarting seamless video loop:', err));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
type VideoEventCallbacks = {
|
type VideoEventCallbacks = {
|
||||||
onEnded: () => void;
|
onEnded: () => void;
|
||||||
|
onTimeUpdate?: () => void;
|
||||||
onPlaybackStateChange: () => void;
|
onPlaybackStateChange: () => void;
|
||||||
onTimelineChange: () => void;
|
onTimelineChange: () => void;
|
||||||
onVolumeChange: () => void;
|
onVolumeChange: () => void;
|
||||||
@@ -12,6 +13,7 @@ type BindVideoEventsOptions = VideoEventCallbacks & {
|
|||||||
|
|
||||||
export function bindVideoEvents({
|
export function bindVideoEvents({
|
||||||
onEnded,
|
onEnded,
|
||||||
|
onTimeUpdate,
|
||||||
onPlaybackStateChange,
|
onPlaybackStateChange,
|
||||||
onTimelineChange,
|
onTimelineChange,
|
||||||
onVolumeChange,
|
onVolumeChange,
|
||||||
@@ -35,6 +37,7 @@ export function bindVideoEvents({
|
|||||||
};
|
};
|
||||||
|
|
||||||
video.ontimeupdate = () => {
|
video.ontimeupdate = () => {
|
||||||
|
onTimeUpdate?.();
|
||||||
if (isFinite(video.duration)) {
|
if (isFinite(video.duration)) {
|
||||||
onTimelineChange();
|
onTimelineChange();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type TwoDModeCallbacks = {
|
|||||||
type TwoDModeOptions = {
|
type TwoDModeOptions = {
|
||||||
callbacks: TwoDModeCallbacks;
|
callbacks: TwoDModeCallbacks;
|
||||||
fullscreenTarget: HTMLElement;
|
fullscreenTarget: HTMLElement;
|
||||||
|
getControlsAutoHideDelayMs?: () => number;
|
||||||
mediaCapabilities: MediaCapabilities;
|
mediaCapabilities: MediaCapabilities;
|
||||||
getActiveContentMesh: () => any;
|
getActiveContentMesh: () => any;
|
||||||
getCamera: () => any;
|
getCamera: () => any;
|
||||||
@@ -92,6 +93,7 @@ export class TwoDMode {
|
|||||||
},
|
},
|
||||||
mediaCapabilities: this.mediaCapabilities,
|
mediaCapabilities: this.mediaCapabilities,
|
||||||
fullscreenTarget: this.fullscreenTarget,
|
fullscreenTarget: this.fullscreenTarget,
|
||||||
|
getAutoHideDelayMs: options.getControlsAutoHideDelayMs,
|
||||||
getIsActive: () => this.active,
|
getIsActive: () => this.active,
|
||||||
playerContainer: this.playerContainer,
|
playerContainer: this.playerContainer,
|
||||||
title: options.title
|
title: options.title
|
||||||
|
|||||||
25
src/vr180player/utils/control-panel-timing.ts
Normal file
25
src/vr180player/utils/control-panel-timing.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export const DEFAULT_MENU_AUTO_HIDE_DELAY_MS = 10000;
|
||||||
|
export const PLAYING_VIDEO_MENU_AUTO_HIDE_DELAY_MS = 2500;
|
||||||
|
export const DEFAULT_2D_CONTROL_AUTO_HIDE_DELAY_MS = 3000;
|
||||||
|
export const PLAYING_VIDEO_2D_CONTROL_AUTO_HIDE_DELAY_MS = 1500;
|
||||||
|
|
||||||
|
type VideoPlaybackState = Pick<HTMLVideoElement, 'ended' | 'paused'>;
|
||||||
|
|
||||||
|
type AutoHideDelayOptions = {
|
||||||
|
idleDelayMs?: number;
|
||||||
|
playingDelayMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isVideoActivelyPlaying(video: VideoPlaybackState | null | undefined): boolean {
|
||||||
|
return Boolean(video && !video.paused && !video.ended);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVideoAwareAutoHideDelayMs(
|
||||||
|
video: VideoPlaybackState | null | undefined,
|
||||||
|
{
|
||||||
|
idleDelayMs = DEFAULT_MENU_AUTO_HIDE_DELAY_MS,
|
||||||
|
playingDelayMs = PLAYING_VIDEO_MENU_AUTO_HIDE_DELAY_MS
|
||||||
|
}: AutoHideDelayOptions = {}
|
||||||
|
): number {
|
||||||
|
return isVideoActivelyPlaying(video) ? playingDelayMs : idleDelayMs;
|
||||||
|
}
|
||||||
@@ -37,6 +37,11 @@ import {
|
|||||||
} from './rendering/renderer-lifecycle.js';
|
} from './rendering/renderer-lifecycle.js';
|
||||||
import { MediaTextureManager } from './rendering/texture-manager.js';
|
import { MediaTextureManager } from './rendering/texture-manager.js';
|
||||||
import type { SupportedMediaAdapter } from './media/media-adapter.js';
|
import type { SupportedMediaAdapter } from './media/media-adapter.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_2D_CONTROL_AUTO_HIDE_DELAY_MS,
|
||||||
|
PLAYING_VIDEO_2D_CONTROL_AUTO_HIDE_DELAY_MS,
|
||||||
|
getVideoAwareAutoHideDelayMs
|
||||||
|
} from './utils/control-panel-timing.js';
|
||||||
|
|
||||||
export class PlayerSession {
|
export class PlayerSession {
|
||||||
private readonly headLockMode: HeadLockMode;
|
private readonly headLockMode: HeadLockMode;
|
||||||
@@ -134,6 +139,7 @@ export class PlayerSession {
|
|||||||
togglePlayPause: () => this.mediaController?.togglePlayPause()
|
togglePlayPause: () => this.mediaController?.togglePlayPause()
|
||||||
},
|
},
|
||||||
fullscreenTarget: this.playerContainer,
|
fullscreenTarget: this.playerContainer,
|
||||||
|
getControlsAutoHideDelayMs: () => this.get2DControlsAutoHideDelayMs(),
|
||||||
mediaCapabilities: this.mediaAdapter.capabilities,
|
mediaCapabilities: this.mediaAdapter.capabilities,
|
||||||
getActiveContentMesh: () => this.activeContentMesh,
|
getActiveContentMesh: () => this.activeContentMesh,
|
||||||
getCamera: () => this.camera2D,
|
getCamera: () => this.camera2D,
|
||||||
@@ -175,8 +181,16 @@ export class PlayerSession {
|
|||||||
bindVideoEvents({
|
bindVideoEvents({
|
||||||
onEnded: () => this.onVideoEnded(),
|
onEnded: () => this.onVideoEnded(),
|
||||||
onPlaybackStateChange: () => {
|
onPlaybackStateChange: () => {
|
||||||
|
const shouldRefreshVrPanelHide = this.vrPanelVisibility.isVisible;
|
||||||
|
this.mediaController?.syncSeamlessLoopMonitor();
|
||||||
this.updateVRPlayPauseButtonIcon();
|
this.updateVRPlayPauseButtonIcon();
|
||||||
this.update2DPlayPauseButton();
|
this.update2DPlayPauseButton();
|
||||||
|
if (shouldRefreshVrPanelHide) {
|
||||||
|
this.showPanel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTimeUpdate: () => {
|
||||||
|
this.mediaController?.handleTimeUpdate();
|
||||||
},
|
},
|
||||||
onTimelineChange: () => {
|
onTimelineChange: () => {
|
||||||
this.updateSeekBarAppearance();
|
this.updateSeekBarAppearance();
|
||||||
@@ -330,7 +344,7 @@ export class PlayerSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private showPanel(): void {
|
private showPanel(): void {
|
||||||
this.vrPanelVisibility.show();
|
this.vrPanelVisibility.show(this.getVrPanelAutoHideDelayMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
private showPanelPersistent(): void {
|
private showPanelPersistent(): void {
|
||||||
@@ -345,6 +359,17 @@ export class PlayerSession {
|
|||||||
return this.vrPanelVisibility.isVisible ? (this.vrPanel?.interactables ?? []) : [];
|
return this.vrPanelVisibility.isVisible ? (this.vrPanel?.interactables ?? []) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getVrPanelAutoHideDelayMs(): number {
|
||||||
|
return getVideoAwareAutoHideDelayMs(this.video);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get2DControlsAutoHideDelayMs(): number {
|
||||||
|
return getVideoAwareAutoHideDelayMs(this.video, {
|
||||||
|
idleDelayMs: DEFAULT_2D_CONTROL_AUTO_HIDE_DELAY_MS,
|
||||||
|
playingDelayMs: PLAYING_VIDEO_2D_CONTROL_AUTO_HIDE_DELAY_MS
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private onWindowResize(): void {
|
private onWindowResize(): void {
|
||||||
if (!this.renderer) return;
|
if (!this.renderer) return;
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import {
|
|||||||
setVrPanelOpacity,
|
setVrPanelOpacity,
|
||||||
type VrControlPanel
|
type VrControlPanel
|
||||||
} from './vr-control-panel.js';
|
} from './vr-control-panel.js';
|
||||||
|
import { DEFAULT_MENU_AUTO_HIDE_DELAY_MS } from '../utils/control-panel-timing.js';
|
||||||
|
|
||||||
const FADE_DURATION_MS = 200;
|
const FADE_DURATION_MS = 200;
|
||||||
const AUTO_HIDE_DELAY_MS = 10000;
|
|
||||||
|
|
||||||
export class VrPanelVisibility {
|
export class VrPanelVisibility {
|
||||||
private hideTimeout: number | undefined;
|
private hideTimeout: number | undefined;
|
||||||
@@ -28,15 +28,15 @@ export class VrPanelVisibility {
|
|||||||
this.hideImmediately();
|
this.hideImmediately();
|
||||||
}
|
}
|
||||||
|
|
||||||
show(): void {
|
show(autoHideDelayMs = DEFAULT_MENU_AUTO_HIDE_DELAY_MS): void {
|
||||||
this.showWithAutoHide(true);
|
this.showWithAutoHide(true, autoHideDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
showPersistent(): void {
|
showPersistent(): void {
|
||||||
this.showWithAutoHide(false);
|
this.showWithAutoHide(false, DEFAULT_MENU_AUTO_HIDE_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
private showWithAutoHide(shouldAutoHide: boolean): void {
|
private showWithAutoHide(shouldAutoHide: boolean, autoHideDelayMs: number): void {
|
||||||
if (this.panel) this.panel.group.visible = true;
|
if (this.panel) this.panel.group.visible = true;
|
||||||
this.clearHideTimeout();
|
this.clearHideTimeout();
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export class VrPanelVisibility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (shouldAutoHide) {
|
if (shouldAutoHide) {
|
||||||
this.hideTimeout = window.setTimeout(() => this.hide(), AUTO_HIDE_DELAY_MS);
|
this.hideTimeout = window.setTimeout(() => this.hide(), autoHideDelayMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/Test.mp4" type="video/mp4">
|
<source src="../media/VR180_SBS_TEST.mp4" type="video/mp4">
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
44
tests/control-panel-timing.test.mjs
Normal file
44
tests/control-panel-timing.test.mjs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_MENU_AUTO_HIDE_DELAY_MS,
|
||||||
|
PLAYING_VIDEO_MENU_AUTO_HIDE_DELAY_MS,
|
||||||
|
getVideoAwareAutoHideDelayMs,
|
||||||
|
isVideoActivelyPlaying
|
||||||
|
} from '../vr180player/utils/control-panel-timing.js';
|
||||||
|
|
||||||
|
test('isVideoActivelyPlaying only returns true for non-paused, non-ended video state', () => {
|
||||||
|
assert.equal(isVideoActivelyPlaying({ paused: false, ended: false }), true);
|
||||||
|
assert.equal(isVideoActivelyPlaying({ paused: true, ended: false }), false);
|
||||||
|
assert.equal(isVideoActivelyPlaying({ paused: false, ended: true }), false);
|
||||||
|
assert.equal(isVideoActivelyPlaying(undefined), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getVideoAwareAutoHideDelayMs uses the shorter delay while video is playing', () => {
|
||||||
|
assert.equal(
|
||||||
|
getVideoAwareAutoHideDelayMs({ paused: false, ended: false }),
|
||||||
|
PLAYING_VIDEO_MENU_AUTO_HIDE_DELAY_MS
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getVideoAwareAutoHideDelayMs({ paused: true, ended: false }),
|
||||||
|
DEFAULT_MENU_AUTO_HIDE_DELAY_MS
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getVideoAwareAutoHideDelayMs accepts custom delay values for other control surfaces', () => {
|
||||||
|
assert.equal(
|
||||||
|
getVideoAwareAutoHideDelayMs(
|
||||||
|
{ paused: false, ended: false },
|
||||||
|
{ idleDelayMs: 3000, playingDelayMs: 1500 }
|
||||||
|
),
|
||||||
|
1500
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getVideoAwareAutoHideDelayMs(
|
||||||
|
{ paused: true, ended: false },
|
||||||
|
{ idleDelayMs: 3000, playingDelayMs: 1500 }
|
||||||
|
),
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
});
|
||||||
67
tests/icons.test.mjs
Normal file
67
tests/icons.test.mjs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { drawLucideIcon } from '../vr180player/dom/icons.js';
|
||||||
|
|
||||||
|
function createCanvasContextRecorder() {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
beginPath() {
|
||||||
|
calls.push(['beginPath']);
|
||||||
|
},
|
||||||
|
closePath() {
|
||||||
|
calls.push(['closePath']);
|
||||||
|
},
|
||||||
|
lineTo(x, y) {
|
||||||
|
calls.push(['lineTo', x, y]);
|
||||||
|
},
|
||||||
|
moveTo(x, y) {
|
||||||
|
calls.push(['moveTo', x, y]);
|
||||||
|
},
|
||||||
|
restore() {
|
||||||
|
calls.push(['restore']);
|
||||||
|
},
|
||||||
|
save() {
|
||||||
|
calls.push(['save']);
|
||||||
|
},
|
||||||
|
scale(x, y) {
|
||||||
|
calls.push(['scale', x, y]);
|
||||||
|
},
|
||||||
|
stroke() {
|
||||||
|
calls.push(['stroke']);
|
||||||
|
},
|
||||||
|
translate(x, y) {
|
||||||
|
calls.push(['translate', x, y]);
|
||||||
|
},
|
||||||
|
set lineCap(value) {
|
||||||
|
calls.push(['lineCap', value]);
|
||||||
|
},
|
||||||
|
set lineJoin(value) {
|
||||||
|
calls.push(['lineJoin', value]);
|
||||||
|
},
|
||||||
|
set lineWidth(value) {
|
||||||
|
calls.push(['lineWidth', value]);
|
||||||
|
},
|
||||||
|
set strokeStyle(value) {
|
||||||
|
calls.push(['strokeStyle', value]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('drawLucideIcon renders space-separated polygon points for the play icon', () => {
|
||||||
|
const ctx = createCanvasContextRecorder();
|
||||||
|
|
||||||
|
drawLucideIcon(ctx, 'play', 0, 0, 24);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
ctx.calls.filter(([name]) => name === 'moveTo' || name === 'lineTo' || name === 'closePath'),
|
||||||
|
[
|
||||||
|
['moveTo', 6, 3],
|
||||||
|
['lineTo', 20, 12],
|
||||||
|
['lineTo', 6, 21],
|
||||||
|
['lineTo', 6, 3],
|
||||||
|
['closePath']
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -119,6 +119,52 @@ test('MediaController toggles loop playback state', () => {
|
|||||||
assert.equal(video.loop, false);
|
assert.equal(video.loop, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('MediaController restarts looping video just before the ended boundary', () => {
|
||||||
|
const { controller, video } = createController({
|
||||||
|
video: createVideo({
|
||||||
|
currentTime: 119.9,
|
||||||
|
duration: 120,
|
||||||
|
loop: true,
|
||||||
|
paused: false
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.handleTimeUpdate();
|
||||||
|
|
||||||
|
assert.equal(video.currentTime, 0);
|
||||||
|
assert.equal(video.playCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MediaController leaves non-looping or paused video alone near the ended boundary', () => {
|
||||||
|
const { controller: nonLoopingController, video: nonLoopingVideo } = createController({
|
||||||
|
video: createVideo({
|
||||||
|
currentTime: 119.9,
|
||||||
|
duration: 120,
|
||||||
|
loop: false,
|
||||||
|
paused: false
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
nonLoopingController.handleTimeUpdate();
|
||||||
|
|
||||||
|
assert.equal(nonLoopingVideo.currentTime, 119.9);
|
||||||
|
assert.equal(nonLoopingVideo.playCount, 0);
|
||||||
|
|
||||||
|
const { controller: pausedController, video: pausedVideo } = createController({
|
||||||
|
video: createVideo({
|
||||||
|
currentTime: 119.9,
|
||||||
|
duration: 120,
|
||||||
|
loop: true,
|
||||||
|
paused: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
pausedController.handleTimeUpdate();
|
||||||
|
|
||||||
|
assert.equal(pausedVideo.currentTime, 119.9);
|
||||||
|
assert.equal(pausedVideo.playCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('MediaController resets video and play button to poster state', () => {
|
test('MediaController resets video and play button to poster state', () => {
|
||||||
const playButton = { classList: createClassList(), disabled: true };
|
const playButton = { classList: createClassList(), disabled: true };
|
||||||
playButton.classList.add('hidden');
|
playButton.classList.add('hidden');
|
||||||
|
|||||||
Reference in New Issue
Block a user