import * as GuidedTourPlayerModule from './GuidedTourPlayer'; type AnyRecord = Record; export type GuidedTourBridgeStatus = 'idle' | 'playing' | 'paused' | 'complete' | 'stopped'; export interface GuidedTourStepSnapshot { id: string; index: number; title: string; caption: string; durationSeconds: number; highlightedPartIds: string[]; cameraPreset?: string; raw: unknown; } export interface GuidedTourBridgeSnapshot { machineId: string; tourId: string; status: GuidedTourBridgeStatus; isPlaying: boolean; isPaused: boolean; activeStepIndex: number; stepCount: number; progress: number; elapsedSeconds: number; stepElapsedSeconds: number; activeStep?: GuidedTourStepSnapshot; highlightedPartIds: string[]; cameraPreset?: string; raw: unknown; } export interface GuidedTourPlayerAdapterOptions { machineId?: string; tourId?: string; autoLoop?: boolean; reducedMotion?: boolean; [key: string]: unknown; } export interface GuidedTourPlayerAdapter { readonly rawPlayer: unknown; readonly tour: unknown; getSnapshot(): GuidedTourBridgeSnapshot; subscribe(listener: (snapshot: GuidedTourBridgeSnapshot) => void): () => void; start(): void; pause(): void; resume(): void; restart(): void; stop(): void; next(): void; previous(): void; goToStep(index: number): void; dispose(): void; } const PLAYER_EXPORT_CANDIDATES = [ 'GuidedTourPlayer', 'TourPlayer', 'MechanicalGuidedTourPlayer', 'default', ]; const START_METHODS = ['start', 'play', 'begin']; const PAUSE_METHODS = ['pause', 'suspend']; const RESUME_METHODS = ['resume', 'play']; const RESTART_METHODS = ['restart', 'replay', 'resetAndPlay']; const STOP_METHODS = ['stop', 'cancel', 'end']; const NEXT_METHODS = ['next', 'nextStep', 'advance', 'advanceStep']; const PREVIOUS_METHODS = ['previous', 'prev', 'previousStep', 'prevStep']; const GO_TO_STEP_METHODS = ['goToStep', 'setStep', 'seekStep', 'jumpToStep']; function isRecord(value: unknown): value is AnyRecord { return (typeof value === 'object' && value !== null) || typeof value === 'function'; } function asRecord(value: unknown): AnyRecord { return isRecord(value) ? (value as AnyRecord) : {}; } function toFiniteNumber(value: unknown, fallback: number): number { if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'string' && value.trim() !== '') { const parsed = Number(value); if (Number.isFinite(parsed)) { return parsed; } } return fallback; } function clamp01(value: number): number { if (!Number.isFinite(value)) { return 0; } return Math.min(1, Math.max(0, value)); } function requestFrame(callback: FrameRequestCallback): number { if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { return window.requestAnimationFrame(callback); } return setTimeout(() => callback(Date.now()), 16) as unknown as number; } function cancelFrame(handle: number): void { if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') { window.cancelAnimationFrame(handle); return; } clearTimeout(handle as unknown as ReturnType); } function performanceNow(): number { if (typeof performance !== 'undefined' && typeof performance.now === 'function') { return performance.now(); } return Date.now(); } function callFirstMethod(target: unknown, names: string[], args: unknown[] = []): boolean { const record = asRecord(target); for (const name of names) { const method = record[name]; if (typeof method !== 'function') { continue; } try { method.apply(target, args); return true; } catch { // Try compatibility aliases before falling back to local behavior. } } return false; } function getTourSteps(tour: unknown): unknown[] { if (Array.isArray(tour)) { return tour; } const record = asRecord(tour); const candidates = [record.steps, record.sequence, record.items, record.tourSteps]; for (const candidate of candidates) { if (Array.isArray(candidate)) { return candidate; } } return []; } function stringFrom(record: AnyRecord, keys: string[], fallback: string): string { for (const key of keys) { const value = record[key]; if (typeof value === 'string' && value.trim() !== '') { return value; } } return fallback; } function stringArrayFrom(value: unknown): string[] { if (typeof value === 'string' && value.trim() !== '') { return [value]; } if (!Array.isArray(value)) { return []; } return value.filter((item): item is string => typeof item === 'string' && item.trim() !== ''); } function normalizeStep(rawStep: unknown, index: number): GuidedTourStepSnapshot { const record = asRecord(rawStep); const highlightedPartIds = [ ...stringArrayFrom(record.highlightedPartIds), ...stringArrayFrom(record.highlightPartIds), ...stringArrayFrom(record.highlightParts), ...stringArrayFrom(record.partIds), ...stringArrayFrom(record.partId), ...stringArrayFrom(record.focusPartId), ]; return { id: stringFrom(record, ['id', 'slug'], `tour-step-${index + 1}`), index, title: stringFrom(record, ['title', 'heading', 'label'], `Step ${index + 1}`), caption: stringFrom( record, ['caption', 'body', 'description', 'text', 'copy'], stringFrom(record, ['title', 'heading', 'label'], `Step ${index + 1}`), ), durationSeconds: Math.max( 0.5, toFiniteNumber( record.durationSeconds ?? record.duration ?? record.holdSeconds ?? record.seconds, 4, ), ), highlightedPartIds: Array.from(new Set(highlightedPartIds)), cameraPreset: stringFrom(record, ['cameraPreset', 'camera', 'view', 'preset'], ''), raw: rawStep, }; } function normalizeStatus(rawStatus: unknown, isPlayingHint: boolean): GuidedTourBridgeStatus { const status = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : ''; if (isPlayingHint || ['play', 'playing', 'running', 'started'].includes(status)) { return 'playing'; } if (['pause', 'paused', 'suspended'].includes(status)) { return 'paused'; } if (['complete', 'completed', 'finished', 'done'].includes(status)) { return 'complete'; } if (['stop', 'stopped', 'cancelled', 'canceled'].includes(status)) { return 'stopped'; } return 'idle'; } function readRawSnapshot(rawPlayer: unknown): unknown { const record = asRecord(rawPlayer); for (const key of ['getSnapshot', 'snapshot', 'getState', 'state', 'current']) { const member = record[key]; if (typeof member === 'function') { try { return member.call(rawPlayer); } catch { continue; } } if (member !== undefined) { return member; } } return record; } export function normalizeGuidedTourSnapshot( rawSnapshot: unknown, tour: unknown, options: GuidedTourPlayerAdapterOptions = {}, ): GuidedTourBridgeSnapshot { const snapshotRecord = asRecord(rawSnapshot); const tourRecord = asRecord(tour); const steps = getTourSteps(tour).map(normalizeStep); const stepCount = Math.max(steps.length, 0); const activeStepIndex = Math.min( Math.max( 0, Math.floor( toFiniteNumber( snapshotRecord.activeStepIndex ?? snapshotRecord.stepIndex ?? snapshotRecord.index ?? snapshotRecord.currentStepIndex, 0, ), ), ), Math.max(0, stepCount - 1), ); const activeStep = steps[activeStepIndex]; const progress = clamp01( toFiniteNumber(snapshotRecord.progress ?? snapshotRecord.stepProgress ?? snapshotRecord.normalizedTime, 0), ); const isPlayingHint = snapshotRecord.isPlaying === true || snapshotRecord.playing === true || snapshotRecord.running === true; const status = normalizeStatus( snapshotRecord.status ?? snapshotRecord.state ?? snapshotRecord.phase, isPlayingHint, ); const highlightedPartIds = stringArrayFrom(snapshotRecord.highlightedPartIds).length > 0 ? stringArrayFrom(snapshotRecord.highlightedPartIds) : activeStep?.highlightedPartIds ?? []; const cameraPreset = stringFrom(snapshotRecord, ['cameraPreset', 'view', 'camera'], '') || activeStep?.cameraPreset || undefined; return { machineId: stringFrom(snapshotRecord, ['machineId', 'machine'], '') || stringFrom(tourRecord, ['machineId', 'machine'], options.machineId ?? 'unknown-machine'), tourId: stringFrom(snapshotRecord, ['tourId', 'id'], '') || stringFrom(tourRecord, ['id', 'slug', 'name'], options.tourId ?? 'guided-tour'), status, isPlaying: status === 'playing', isPaused: status === 'paused', activeStepIndex, stepCount, progress, elapsedSeconds: Math.max( 0, toFiniteNumber(snapshotRecord.elapsedSeconds ?? snapshotRecord.elapsed ?? snapshotRecord.time, 0), ), stepElapsedSeconds: Math.max( 0, toFiniteNumber(snapshotRecord.stepElapsedSeconds ?? snapshotRecord.stepElapsed, 0), ), activeStep, highlightedPartIds, cameraPreset, raw: rawSnapshot, }; } export function createIdleGuidedTourSnapshot( tour: unknown, options: GuidedTourPlayerAdapterOptions = {}, ): GuidedTourBridgeSnapshot { return normalizeGuidedTourSnapshot( { status: 'idle', activeStepIndex: 0, progress: 0, elapsedSeconds: 0, stepElapsedSeconds: 0, }, tour, options, ); } function resolveTourPlayerConstructorOrFactory(): unknown { const moduleRecord = GuidedTourPlayerModule as AnyRecord; for (const exportName of PLAYER_EXPORT_CANDIDATES) { const candidate = moduleRecord[exportName]; if (typeof candidate === 'function') { return candidate; } } for (const candidate of Object.values(moduleRecord)) { if (typeof candidate === 'function') { return candidate; } } return undefined; } function instantiateTourPlayer( tour: unknown, options: GuidedTourPlayerAdapterOptions, ): unknown { const constructorOrFactory = resolveTourPlayerConstructorOrFactory(); if (typeof constructorOrFactory !== 'function') { return new LocalGuidedTourPlayer(tour, options); } const factoryRecord = asRecord(constructorOrFactory); const attempts: Array<() => unknown> = [ () => new (constructorOrFactory as new (...args: unknown[]) => unknown)(tour, options), () => new (constructorOrFactory as new (...args: unknown[]) => unknown)({ ...options, tour, guidedTour: tour, }), () => (constructorOrFactory as (...args: unknown[]) => unknown)(tour, options), ]; const createMember = factoryRecord.create; if (typeof createMember === 'function') { attempts.push(() => createMember.call(constructorOrFactory, tour, options)); attempts.push(() => createMember.call(constructorOrFactory, { ...options, tour, guidedTour: tour, }), ); } for (const attempt of attempts) { try { const player = attempt(); if (player !== undefined && player !== null) { return player; } } catch { // Try the next known construction shape. } } return new LocalGuidedTourPlayer(tour, options); } function subscribeToRawPlayer( rawPlayer: unknown, listener: (rawSnapshot: unknown) => void, ): () => void { const record = asRecord(rawPlayer); for (const methodName of ['subscribe', 'onSnapshot', 'addSnapshotListener', 'listen']) { const method = record[methodName]; if (typeof method !== 'function') { continue; } try { const unsubscribe = method.call(rawPlayer, listener); if (typeof unsubscribe === 'function') { return unsubscribe as () => void; } if (isRecord(unsubscribe) && typeof unsubscribe.unsubscribe === 'function') { return () => { (unsubscribe.unsubscribe as () => void).call(unsubscribe); }; } if (isRecord(unsubscribe) && typeof unsubscribe.dispose === 'function') { return () => { (unsubscribe.dispose as () => void).call(unsubscribe); }; } return () => { callFirstMethod(rawPlayer, ['unsubscribe', 'removeSnapshotListener', 'unlisten'], [ listener, ]); }; } catch { // Try the next listener API. } } if (typeof record.on === 'function') { try { record.on.call(rawPlayer, 'snapshot', listener); return () => { const off = record.off ?? record.removeListener; if (typeof off === 'function') { off.call(rawPlayer, 'snapshot', listener); } }; } catch { // Fall through to RAF polling. } } let disposed = false; let frameHandle: number | undefined; const tick = () => { if (disposed) { return; } listener(readRawSnapshot(rawPlayer)); frameHandle = requestFrame(tick); }; frameHandle = requestFrame(tick); return () => { disposed = true; if (frameHandle !== undefined) { cancelFrame(frameHandle); } }; } class LocalGuidedTourPlayer { private readonly tour: unknown; private readonly options: GuidedTourPlayerAdapterOptions; private readonly listeners = new Set<(snapshot: unknown) => void>(); private frameHandle: number | undefined; private lastTimestamp = 0; private snapshot: GuidedTourBridgeSnapshot; private disposed = false; constructor(tour: unknown, options: GuidedTourPlayerAdapterOptions) { this.tour = tour; this.options = options; this.snapshot = createIdleGuidedTourSnapshot(tour, options); } getSnapshot(): GuidedTourBridgeSnapshot { return this.snapshot; } subscribe(listener: (snapshot: unknown) => void): () => void { this.listeners.add(listener); listener(this.snapshot); return () => { this.listeners.delete(listener); }; } start(): void { if (this.disposed || this.snapshot.stepCount === 0) { return; } this.snapshot = { ...this.snapshot, status: 'playing', isPlaying: true, isPaused: false, }; this.emit(); this.lastTimestamp = performanceNow(); if (this.frameHandle !== undefined) { cancelFrame(this.frameHandle); } this.frameHandle = requestFrame(this.tick); } play(): void { this.start(); } pause(): void { if (this.frameHandle !== undefined) { cancelFrame(this.frameHandle); this.frameHandle = undefined; } this.snapshot = { ...this.snapshot, status: 'paused', isPlaying: false, isPaused: true, }; this.emit(); } resume(): void { this.start(); } restart(): void { this.goToStep(0); this.snapshot = { ...this.snapshot, elapsedSeconds: 0, stepElapsedSeconds: 0, progress: 0, }; this.start(); } stop(): void { if (this.frameHandle !== undefined) { cancelFrame(this.frameHandle); this.frameHandle = undefined; } this.snapshot = { ...this.snapshot, status: 'stopped', isPlaying: false, isPaused: false, progress: 0, }; this.emit(); } next(): void { this.goToStep(Math.min(this.snapshot.stepCount - 1, this.snapshot.activeStepIndex + 1)); } previous(): void { this.goToStep(Math.max(0, this.snapshot.activeStepIndex - 1)); } goToStep(index: number): void { const nextIndex = Math.min(Math.max(0, Math.floor(index)), Math.max(0, this.snapshot.stepCount - 1)); const steps = getTourSteps(this.tour).map(normalizeStep); const nextStep = steps[nextIndex]; this.snapshot = { ...this.snapshot, activeStepIndex: nextIndex, activeStep: nextStep, highlightedPartIds: nextStep?.highlightedPartIds ?? [], cameraPreset: nextStep?.cameraPreset, progress: 0, stepElapsedSeconds: 0, }; this.emit(); } dispose(): void { this.disposed = true; if (this.frameHandle !== undefined) { cancelFrame(this.frameHandle); this.frameHandle = undefined; } this.listeners.clear(); } private readonly tick = (timestamp: number): void => { if (this.disposed || !this.snapshot.isPlaying) { return; } const deltaSeconds = Math.max(0, (timestamp - this.lastTimestamp) / 1000); this.lastTimestamp = timestamp; const activeStep = this.snapshot.activeStep; const durationSeconds = activeStep?.durationSeconds ?? 4; const stepElapsedSeconds = this.snapshot.stepElapsedSeconds + deltaSeconds; const progress = clamp01(stepElapsedSeconds / durationSeconds); const elapsedSeconds = this.snapshot.elapsedSeconds + deltaSeconds; this.snapshot = { ...this.snapshot, elapsedSeconds, stepElapsedSeconds, progress, }; if (progress >= 1) { if (this.snapshot.activeStepIndex < this.snapshot.stepCount - 1) { this.goToStep(this.snapshot.activeStepIndex + 1); this.snapshot = { ...this.snapshot, status: 'playing', isPlaying: true, isPaused: false, elapsedSeconds, }; } else if (this.options.autoLoop) { this.goToStep(0); this.snapshot = { ...this.snapshot, status: 'playing', isPlaying: true, isPaused: false, elapsedSeconds, }; } else { this.snapshot = { ...this.snapshot, status: 'complete', isPlaying: false, isPaused: false, progress: 1, }; this.emit(); return; } } this.emit(); this.frameHandle = requestFrame(this.tick); }; private emit(): void { for (const listener of this.listeners) { listener(this.snapshot); } } } export function createGuidedTourPlayerAdapter( tour: unknown, options: GuidedTourPlayerAdapterOptions = {}, ): GuidedTourPlayerAdapter { const rawPlayer = instantiateTourPlayer(tour, options); const listeners = new Set<(snapshot: GuidedTourBridgeSnapshot) => void>(); let rawUnsubscribe: (() => void) | undefined; let disposed = false; let lastSnapshot = normalizeGuidedTourSnapshot(readRawSnapshot(rawPlayer), tour, options); const emitSnapshot = (rawSnapshot: unknown = readRawSnapshot(rawPlayer)) => { lastSnapshot = normalizeGuidedTourSnapshot(rawSnapshot, tour, options); for (const listener of listeners) { listener(lastSnapshot); } }; const ensureRawSubscription = () => { if (rawUnsubscribe || disposed) { return; } rawUnsubscribe = subscribeToRawPlayer(rawPlayer, emitSnapshot); }; const invokeAndEmit = (methodNames: string[], args: unknown[] = []) => { callFirstMethod(rawPlayer, methodNames, args); emitSnapshot(); }; const adapter: GuidedTourPlayerAdapter = { rawPlayer, tour, getSnapshot() { lastSnapshot = normalizeGuidedTourSnapshot(readRawSnapshot(rawPlayer), tour, options); return lastSnapshot; }, subscribe(listener) { listeners.add(listener); listener(lastSnapshot); ensureRawSubscription(); return () => { listeners.delete(listener); if (listeners.size === 0 && rawUnsubscribe) { rawUnsubscribe(); rawUnsubscribe = undefined; } }; }, start() { invokeAndEmit(START_METHODS); }, pause() { invokeAndEmit(PAUSE_METHODS); }, resume() { invokeAndEmit(RESUME_METHODS); }, restart() { invokeAndEmit(RESTART_METHODS); }, stop() { invokeAndEmit(STOP_METHODS); }, next() { invokeAndEmit(NEXT_METHODS); }, previous() { invokeAndEmit(PREVIOUS_METHODS); }, goToStep(index: number) { invokeAndEmit(GO_TO_STEP_METHODS, [index]); }, dispose() { disposed = true; if (rawUnsubscribe) { rawUnsubscribe(); rawUnsubscribe = undefined; } callFirstMethod(rawPlayer, ['dispose', 'destroy', 'teardown']); listeners.clear(); }, }; return adapter; }