1
0

Updates
All checks were successful
Test / test (push) Successful in 9m33s

This commit is contained in:
Aiden
2026-06-11 05:48:19 +10:00
parent a470d4bdc7
commit fbdb733f13
13 changed files with 310 additions and 15 deletions

View File

@@ -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();

View File

@@ -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');

View File

@@ -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<void> {
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<void> | 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<void> | undefined;
playPromise?.catch((err) => console.error('Error restarting seamless video loop:', err));
return true;
}
}

View File

@@ -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();
}

View File

@@ -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

View 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;
}

View File

@@ -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;

View File

@@ -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);
}
}