diff --git a/package.json b/package.json index dfb5a7d..8050766 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "npm run build && vite --host 0.0.0.0", "build": "tsc && node scripts/copy-styles.mjs", "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" }, "devDependencies": { diff --git a/src/vr180player/dom/icons.ts b/src/vr180player/dom/icons.ts index cb13aa8..a6784a5 100644 --- a/src/vr180player/dom/icons.ts +++ b/src/vr180player/dom/icons.ts @@ -160,7 +160,15 @@ function drawIconNode(ctx: CanvasRenderingContext2D, tagName: string, attrs: Ico } 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; ctx.beginPath(); diff --git a/src/vr180player/dom/two-d-control-panel.ts b/src/vr180player/dom/two-d-control-panel.ts index 12ebf22..d8d3111 100644 --- a/src/vr180player/dom/two-d-control-panel.ts +++ b/src/vr180player/dom/two-d-control-panel.ts @@ -1,6 +1,7 @@ import { setLucideIcon } from './icons.js'; import { formatTime } from '../utils/time.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 = { getIsLooping: () => boolean; @@ -15,17 +16,17 @@ type TwoDControlPanelCallbacks = { type TwoDControlPanelOptions = { callbacks: TwoDControlPanelCallbacks; fullscreenTarget: HTMLElement; + getAutoHideDelayMs?: () => number; getIsActive: () => boolean; mediaCapabilities: MediaCapabilities; playerContainer: HTMLElement; title: string; }; -const CONTROL_PANEL_HIDE_DELAY = 3000; - export class TwoDControlPanel { private readonly callbacks: TwoDControlPanelCallbacks; private readonly fullscreenTarget: HTMLElement; + private readonly getAutoHideDelayMs: () => number; private readonly getIsActive: () => boolean; private readonly playerContainer: HTMLElement; private controlPanel: HTMLElement | null; @@ -42,9 +43,10 @@ export class TwoDControlPanel { private navControls: 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.fullscreenTarget = fullscreenTarget; + this.getAutoHideDelayMs = getAutoHideDelayMs ?? (() => DEFAULT_2D_CONTROL_AUTO_HIDE_DELAY_MS); this.getIsActive = getIsActive; this.playerContainer = playerContainer; @@ -81,7 +83,7 @@ export class TwoDControlPanel { this.clearHideTimeout(); 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 { @@ -141,6 +143,8 @@ export class TwoDControlPanel { this.playButton.classList.add('playing'); setLucideIcon(this.playButton, 'pause'); } + + this.refreshAutoHideIfVisible(); } 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 { if (this.backButton) { this.backButton.setAttribute('aria-label', 'Previous image'); diff --git a/src/vr180player/media/media-controller.ts b/src/vr180player/media/media-controller.ts index 44a53a3..e3dc36d 100644 --- a/src/vr180player/media/media-controller.ts +++ b/src/vr180player/media/media-controller.ts @@ -14,12 +14,14 @@ type HandleMediaEndedOptions = { }; const DEFAULT_SKIP_SECONDS = 15; +const SEAMLESS_LOOP_LOOKAHEAD_SECONDS = 0.18; export class MediaController { private readonly is2DModeActive: () => boolean; private readonly on2DPlaybackResume: () => void; private readonly playButton?: HTMLButtonElement; private readonly video: HTMLVideoElement; + private loopFrameCallbackId: number | undefined; constructor({ is2DModeActive, on2DPlaybackResume, playButton, video }: MediaControllerOptions) { this.is2DModeActive = is2DModeActive; @@ -38,6 +40,10 @@ export class MediaController { this.video.currentTime = Math.min(this.video.duration, this.video.currentTime + seconds); } + handleTimeUpdate(): void { + this.loopBeforeEndedIfNeeded(); + } + handleEnded({ isIn2DMode, isInVr, @@ -72,13 +78,17 @@ export class MediaController { if (!this.video.paused) { this.video.pause(); } + this.syncSeamlessLoopMonitor(); } play(): Promise { - return this.video.play(); + const playPromise = this.video.play(); + playPromise.then(() => this.syncSeamlessLoopMonitor()).catch(() => {}); + return playPromise; } resetToOriginalState(): void { + this.stopSeamlessLoopMonitor(); this.video.pause(); this.video.currentTime = 0; this.video.controls = false; @@ -106,6 +116,7 @@ export class MediaController { toggleLoop(): boolean { this.video.loop = !this.video.loop; + this.syncSeamlessLoopMonitor(); return this.video.loop; } @@ -121,6 +132,7 @@ export class MediaController { const playPromise = this.video.play() as Promise | undefined; if (playPromise !== undefined) { playPromise.then(() => { + this.syncSeamlessLoopMonitor(); if (this.is2DModeActive() && this.video.ended === false) { this.on2DPlaybackResume(); } @@ -133,5 +145,56 @@ export class MediaController { } 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 | undefined; + playPromise?.catch((err) => console.error('Error restarting seamless video loop:', err)); + return true; } } diff --git a/src/vr180player/media/video-events.ts b/src/vr180player/media/video-events.ts index 465c2cc..50ca9be 100644 --- a/src/vr180player/media/video-events.ts +++ b/src/vr180player/media/video-events.ts @@ -1,5 +1,6 @@ type VideoEventCallbacks = { onEnded: () => void; + onTimeUpdate?: () => void; onPlaybackStateChange: () => void; onTimelineChange: () => void; onVolumeChange: () => void; @@ -12,6 +13,7 @@ type BindVideoEventsOptions = VideoEventCallbacks & { export function bindVideoEvents({ onEnded, + onTimeUpdate, onPlaybackStateChange, onTimelineChange, onVolumeChange, @@ -35,6 +37,7 @@ export function bindVideoEvents({ }; video.ontimeupdate = () => { + onTimeUpdate?.(); if (isFinite(video.duration)) { onTimelineChange(); } diff --git a/src/vr180player/modes/two-d-mode.ts b/src/vr180player/modes/two-d-mode.ts index dcbdda9..b787a18 100644 --- a/src/vr180player/modes/two-d-mode.ts +++ b/src/vr180player/modes/two-d-mode.ts @@ -24,6 +24,7 @@ type TwoDModeCallbacks = { type TwoDModeOptions = { callbacks: TwoDModeCallbacks; fullscreenTarget: HTMLElement; + getControlsAutoHideDelayMs?: () => number; mediaCapabilities: MediaCapabilities; getActiveContentMesh: () => any; getCamera: () => any; @@ -92,6 +93,7 @@ export class TwoDMode { }, mediaCapabilities: this.mediaCapabilities, fullscreenTarget: this.fullscreenTarget, + getAutoHideDelayMs: options.getControlsAutoHideDelayMs, getIsActive: () => this.active, playerContainer: this.playerContainer, title: options.title diff --git a/src/vr180player/utils/control-panel-timing.ts b/src/vr180player/utils/control-panel-timing.ts new file mode 100644 index 0000000..bddb625 --- /dev/null +++ b/src/vr180player/utils/control-panel-timing.ts @@ -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; + +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; +} diff --git a/src/vr180player/vr180-player.ts b/src/vr180player/vr180-player.ts index 98bbc31..f35d9ad 100644 --- a/src/vr180player/vr180-player.ts +++ b/src/vr180player/vr180-player.ts @@ -37,6 +37,11 @@ import { } from './rendering/renderer-lifecycle.js'; import { MediaTextureManager } from './rendering/texture-manager.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 { private readonly headLockMode: HeadLockMode; @@ -134,6 +139,7 @@ export class PlayerSession { togglePlayPause: () => this.mediaController?.togglePlayPause() }, fullscreenTarget: this.playerContainer, + getControlsAutoHideDelayMs: () => this.get2DControlsAutoHideDelayMs(), mediaCapabilities: this.mediaAdapter.capabilities, getActiveContentMesh: () => this.activeContentMesh, getCamera: () => this.camera2D, @@ -175,8 +181,16 @@ export class PlayerSession { bindVideoEvents({ onEnded: () => this.onVideoEnded(), onPlaybackStateChange: () => { + const shouldRefreshVrPanelHide = this.vrPanelVisibility.isVisible; + this.mediaController?.syncSeamlessLoopMonitor(); this.updateVRPlayPauseButtonIcon(); this.update2DPlayPauseButton(); + if (shouldRefreshVrPanelHide) { + this.showPanel(); + } + }, + onTimeUpdate: () => { + this.mediaController?.handleTimeUpdate(); }, onTimelineChange: () => { this.updateSeekBarAppearance(); @@ -330,7 +344,7 @@ export class PlayerSession { } private showPanel(): void { - this.vrPanelVisibility.show(); + this.vrPanelVisibility.show(this.getVrPanelAutoHideDelayMs()); } private showPanelPersistent(): void { @@ -345,6 +359,17 @@ export class PlayerSession { 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 { if (!this.renderer) return; diff --git a/src/vr180player/xr/vr-panel-visibility.ts b/src/vr180player/xr/vr-panel-visibility.ts index 48d1fdf..f5a7483 100644 --- a/src/vr180player/xr/vr-panel-visibility.ts +++ b/src/vr180player/xr/vr-panel-visibility.ts @@ -3,9 +3,9 @@ import { setVrPanelOpacity, type VrControlPanel } from './vr-control-panel.js'; +import { DEFAULT_MENU_AUTO_HIDE_DELAY_MS } from '../utils/control-panel-timing.js'; const FADE_DURATION_MS = 200; -const AUTO_HIDE_DELAY_MS = 10000; export class VrPanelVisibility { private hideTimeout: number | undefined; @@ -28,15 +28,15 @@ export class VrPanelVisibility { this.hideImmediately(); } - show(): void { - this.showWithAutoHide(true); + show(autoHideDelayMs = DEFAULT_MENU_AUTO_HIDE_DELAY_MS): void { + this.showWithAutoHide(true, autoHideDelayMs); } 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; this.clearHideTimeout(); @@ -46,7 +46,7 @@ export class VrPanelVisibility { } if (shouldAutoHide) { - this.hideTimeout = window.setTimeout(() => this.hide(), AUTO_HIDE_DELAY_MS); + this.hideTimeout = window.setTimeout(() => this.hide(), autoHideDelayMs); } } diff --git a/test-pages/test-vr180-3d-video.html b/test-pages/test-vr180-3d-video.html index 116f0c0..2aaeef2 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/control-panel-timing.test.mjs b/tests/control-panel-timing.test.mjs new file mode 100644 index 0000000..0462ce6 --- /dev/null +++ b/tests/control-panel-timing.test.mjs @@ -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 + ); +}); diff --git a/tests/icons.test.mjs b/tests/icons.test.mjs new file mode 100644 index 0000000..984e8a9 --- /dev/null +++ b/tests/icons.test.mjs @@ -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'] + ] + ); +}); diff --git a/tests/media-controller.test.mjs b/tests/media-controller.test.mjs index 80c7b77..3d60465 100644 --- a/tests/media-controller.test.mjs +++ b/tests/media-controller.test.mjs @@ -119,6 +119,52 @@ test('MediaController toggles loop playback state', () => { 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', () => { const playButton = { classList: createClassList(), disabled: true }; playButton.classList.add('hidden');