import { gsap } from 'gsap'; import { getGuidedTourDurationSeconds, normalizeTourStepIndex, type GuidedTour, type GuidedTourSinks, type GuidedTourSnapshot, type GuidedTourStatus, type GuidedTourSubscriber, } from './guidedTour'; const MAX_TOUR_FRAME_DELTA_SECONDS = 0.1; function nowSeconds(): number { if (typeof performance !== 'undefined' && typeof performance.now === 'function') { return performance.now() / 1000; } return Date.now() / 1000; } export class GuidedTourPlayer { private tour: GuidedTour | null; private sinks: GuidedTourSinks; private readonly subscribers = new Set(); private readonly ticker = () => this.onTicker(); private tickerActive = false; private lastTickSeconds = nowSeconds(); private snapshot: GuidedTourSnapshot = { tourId: null, machineId: null, status: 'idle', currentStepIndex: 0, currentStep: null, elapsedInStepSeconds: 0, totalElapsedSeconds: 0, progress: 0, stepProgress: 0, reducedMotion: false, }; constructor(tour: GuidedTour | null | undefined, sinks: GuidedTourSinks = {}) { this.tour = tour ?? null; this.sinks = sinks; this.snapshot = this.createInitialSnapshot('idle'); } setTour(tour: GuidedTour | null | undefined): void { this.stopTicker(); this.tour = tour ?? null; this.snapshot = this.createInitialSnapshot('idle'); this.enterStep(0); this.notify(); } setSinks(sinks: GuidedTourSinks): void { this.sinks = sinks; } setReducedMotion(enabled: boolean): void { this.snapshot = { ...this.snapshot, reducedMotion: enabled }; this.notify(); } getSnapshot(): GuidedTourSnapshot { return { ...this.snapshot }; } subscribe(subscriber: GuidedTourSubscriber): () => void { this.subscribers.add(subscriber); subscriber(this.getSnapshot()); return () => { this.subscribers.delete(subscriber); }; } play(): void { if (!this.tour || this.tour.steps.length === 0) return; if (this.snapshot.status === 'complete') { this.restart(true); return; } this.snapshot = { ...this.snapshot, status: 'playing' }; this.enterStep(this.snapshot.currentStepIndex); this.startTicker(); this.notify(); } pause(): void { if (this.snapshot.status !== 'playing') return; this.snapshot = { ...this.snapshot, status: 'paused' }; this.stopTicker(); this.notify(); } resume(): void { if (!this.tour || this.snapshot.status === 'playing') return; this.snapshot = { ...this.snapshot, status: 'playing' }; this.startTicker(); this.notify(); } restart(autoPlay = false): void { this.stopTicker(); this.snapshot = this.createInitialSnapshot(autoPlay ? 'playing' : 'idle'); this.enterStep(0); this.notify(); if (autoPlay) this.startTicker(); } next(): void { if (!this.tour) return; const nextIndex = this.snapshot.currentStepIndex + 1; if (nextIndex >= this.tour.steps.length) { this.complete(); return; } this.snapshot = { ...this.snapshot, currentStepIndex: nextIndex, elapsedInStepSeconds: 0, }; this.recalculateProgress(); this.enterStep(nextIndex); this.notify(); } previous(): void { if (!this.tour) return; const previousIndex = Math.max(0, this.snapshot.currentStepIndex - 1); this.snapshot = { ...this.snapshot, status: this.snapshot.status === 'complete' ? 'paused' : this.snapshot.status, currentStepIndex: previousIndex, elapsedInStepSeconds: 0, }; this.recalculateProgress(); this.enterStep(previousIndex); this.notify(); } seekStep(stepIndex: number): void { if (!this.tour) return; const index = normalizeTourStepIndex(this.tour, stepIndex); this.snapshot = { ...this.snapshot, currentStepIndex: index, elapsedInStepSeconds: 0, status: this.snapshot.status === 'complete' ? 'paused' : this.snapshot.status, }; this.recalculateProgress(); this.enterStep(index); this.notify(); } dispose(): void { this.stopTicker(); this.subscribers.clear(); this.sinks.highlightParts?.([], 0); } private createInitialSnapshot(status: GuidedTourStatus): GuidedTourSnapshot { const firstStep = this.tour?.steps[0] ?? null; return { tourId: this.tour?.id ?? null, machineId: this.tour?.machineId ?? null, status, currentStepIndex: 0, currentStep: firstStep, elapsedInStepSeconds: 0, totalElapsedSeconds: 0, progress: 0, stepProgress: 0, reducedMotion: this.snapshot?.reducedMotion ?? false, }; } private startTicker(): void { if (this.tickerActive) return; this.lastTickSeconds = nowSeconds(); gsap.ticker.add(this.ticker); this.tickerActive = true; } private stopTicker(): void { if (!this.tickerActive) return; gsap.ticker.remove(this.ticker); this.tickerActive = false; } private onTicker(): void { if (!this.tour || this.snapshot.status !== 'playing') return; const currentSeconds = nowSeconds(); const delta = Math.min(MAX_TOUR_FRAME_DELTA_SECONDS, Math.max(0, currentSeconds - this.lastTickSeconds)); this.lastTickSeconds = currentSeconds; const currentStep = this.tour.steps[this.snapshot.currentStepIndex]; const duration = Math.max(0.1, currentStep.durationSeconds); const elapsed = this.snapshot.elapsedInStepSeconds + delta; this.snapshot = { ...this.snapshot, elapsedInStepSeconds: elapsed, }; if (elapsed >= duration) { this.next(); return; } this.recalculateProgress(); this.notify(); } private enterStep(stepIndex: number): void { if (!this.tour) return; const step = this.tour.steps[stepIndex] ?? null; this.snapshot = { ...this.snapshot, currentStep: step, currentStepIndex: step ? stepIndex : 0, }; if (!step) { this.sinks.highlightParts?.([], 0); return; } const cameraDuration = this.snapshot.reducedMotion ? 0 : (step.camera?.duration ?? 1.2); this.sinks.highlightParts?.(step.partIds, step.highlightIntensity ?? 1); if (step.camera) this.sinks.requestCamera?.(step.camera, cameraDuration); this.sinks.setAnimationPhase?.(step.phaseRange, step.rpm); this.sinks.onStepChange?.(this.getSnapshot(), step); } private recalculateProgress(): void { if (!this.tour) return; const stepStart = this.tour.steps .slice(0, this.snapshot.currentStepIndex) .reduce((total, step) => total + Math.max(0.1, step.durationSeconds), 0); const currentStep = this.tour.steps[this.snapshot.currentStepIndex]; const currentStepDuration = Math.max(0.1, currentStep?.durationSeconds ?? 1); const totalDuration = Math.max(0.1, getGuidedTourDurationSeconds(this.tour)); const totalElapsed = Math.min(totalDuration, stepStart + this.snapshot.elapsedInStepSeconds); this.snapshot = { ...this.snapshot, totalElapsedSeconds: totalElapsed, progress: totalElapsed / totalDuration, stepProgress: Math.min(1, this.snapshot.elapsedInStepSeconds / currentStepDuration), currentStep: currentStep ?? null, }; } private complete(): void { this.stopTicker(); this.snapshot = { ...this.snapshot, status: 'complete', progress: 1, stepProgress: 1, }; this.sinks.highlightParts?.([], 0); this.sinks.onComplete?.(); this.notify(); } private notify(): void { const snapshot = this.getSnapshot(); this.subscribers.forEach((subscriber) => subscriber(snapshot)); } }