function nextAnimationFrame() { return new Promise((resolve) => { window.requestAnimationFrame(() => resolve()); }); } export class CruiseSequencer { constructor({ isActive, getItems, getItemId, focusItem, presentItem, hideItem, clearCurrent, onStop, presentationMode = "auto_advance", dwellMs = 2400, transitionGapMs = 24, }) { this.isActive = isActive; this.getItems = getItems; this.getItemId = getItemId; this.focusItem = focusItem; this.presentItem = presentItem; this.hideItem = hideItem; this.clearCurrent = clearCurrent; this.onStop = onStop; this.presentationMode = presentationMode === "pinned" ? "pinned" : "auto_advance"; this.dwellMs = dwellMs; this.transitionGapMs = transitionGapMs; this.currentItemId = null; this.currentIndex = -1; this.queuedItemIds = []; this.sequenceToken = 0; this.advanceQueued = false; this.advanceInterrupt = false; this.advanceInFlight = false; this.advanceLoopToken = 0; this.primaryTimerId = null; this.secondaryTimerId = null; this.presentationVisible = false; this.currentItem = null; } getCurrentItem() { if (!this.currentItemId) return null; return this.getItems().find((item) => this.getItemId(item) === this.currentItemId) || this.currentItem || null; } getCurrentItemId() { return this.currentItemId; } isPresentationPinned() { return this.presentationVisible; } isBusy() { return this.advanceInFlight || this.presentationVisible; } isPinnedMode() { return this.presentationMode === "pinned"; } enqueue(itemIds = []) { if (!Array.isArray(itemIds) || itemIds.length === 0) return; this.queuedItemIds = Array.from( new Set([...itemIds.filter(Boolean), ...this.queuedItemIds]), ); } setPresentationVisible(visible) { this.presentationVisible = Boolean(visible); } clearTimers() { if (this.primaryTimerId) { clearTimeout(this.primaryTimerId); this.primaryTimerId = null; } if (this.secondaryTimerId) { clearTimeout(this.secondaryTimerId); this.secondaryTimerId = null; } } interruptPresentation({ preservePresentation = false, resetLoop = false } = {}) { this.sequenceToken += 1; this.clearTimers(); this.advanceQueued = false; this.advanceInterrupt = false; if (resetLoop) { this.advanceLoopToken += 1; this.advanceInFlight = false; } if (!preservePresentation) { this.presentationVisible = false; this.currentItem = null; this.clearCurrent?.(); } } stop({ preservePresentation = false } = {}) { this.interruptPresentation({ preservePresentation }); this.currentItem = preservePresentation ? this.currentItem : null; this.currentItemId = preservePresentation ? this.currentItemId : null; this.currentIndex = preservePresentation ? this.currentIndex : -1; this.queuedItemIds = []; this.onStop?.({ preservePresentation }); } createContext(token) { return { token, isCurrent: () => token === this.sequenceToken && this.isActive(), wait: (durationMs, { secondary = false } = {}) => new Promise((resolve) => { const timerId = window.setTimeout(() => { if (secondary) { if (this.secondaryTimerId === timerId) this.secondaryTimerId = null; } else if (this.primaryTimerId === timerId) { this.primaryTimerId = null; } resolve(token === this.sequenceToken && this.isActive()); }, durationMs); if (secondary) { this.secondaryTimerId = timerId; } else { this.primaryTimerId = timerId; } }), nextFrame: nextAnimationFrame, setPresentationVisible: (visible) => { if (token !== this.sequenceToken) return; this.presentationVisible = Boolean(visible); }, }; } resolveNextItem(items) { let targetItem = null; while (this.queuedItemIds.length > 0 && !targetItem) { const queuedId = this.queuedItemIds.shift(); targetItem = items.find((item) => this.getItemId(item) === queuedId) || null; } if (targetItem) return targetItem; const nextIndex = this.currentIndex >= 0 ? (this.currentIndex + 1) % items.length : 0; return items[nextIndex] || items[0] || null; } async presentResolvedItem(targetItem, { interrupt = false } = {}) { if (!this.isActive()) return; if (!targetItem) return; const items = this.getItems(); const token = ++this.sequenceToken; const context = this.createContext(token); this.clearTimers(); this.presentationVisible = false; this.clearCurrent?.(); this.currentItem = targetItem; this.currentItemId = this.getItemId(targetItem); this.currentIndex = Array.isArray(items) ? items.findIndex((item) => this.getItemId(item) === this.currentItemId) : -1; await this.focusItem?.(targetItem, { interrupt, context }); if (!context.isCurrent()) { this.presentationVisible = false; return; } const presented = await this.presentItem?.(targetItem, { interrupt, context }); if (!presented || !context.isCurrent()) { this.presentationVisible = false; return; } this.presentationVisible = true; if (this.isPinnedMode()) { return true; } const dwellCompleted = await context.wait(this.dwellMs); if (!dwellCompleted || !context.isCurrent()) { this.presentationVisible = false; return; } await this.hideItem?.(targetItem, { context }); if (!context.isCurrent()) { this.presentationVisible = false; return; } this.presentationVisible = false; const gapCompleted = await context.wait(this.transitionGapMs, { secondary: true }); if (!gapCompleted || !context.isCurrent()) { return; } void this.advance(); return true; } async performAdvance({ interrupt = false } = {}) { if (!this.isActive()) return; const items = this.getItems(); if (!Array.isArray(items) || items.length === 0) return; const targetItem = this.resolveNextItem(items); return this.presentResolvedItem(targetItem, { interrupt }); } async presentSpecificItem(item, { interrupt = false } = {}) { if (!this.isActive() || !item) return false; this.advanceLoopToken += 1; this.advanceQueued = false; this.advanceInterrupt = false; this.advanceInFlight = false; return Boolean(await this.presentResolvedItem(item, { interrupt })); } async advance({ interrupt = false } = {}) { if (!this.isActive()) return; this.advanceQueued = true; this.advanceInterrupt = this.advanceInterrupt || interrupt; if (this.advanceInFlight) return; const activeLoopToken = ++this.advanceLoopToken; this.advanceInFlight = true; try { while ( this.advanceQueued && this.isActive() && this.advanceLoopToken === activeLoopToken ) { const nextInterrupt = this.advanceInterrupt; this.advanceQueued = false; this.advanceInterrupt = false; await this.performAdvance({ interrupt: nextInterrupt }); } } finally { if (this.advanceLoopToken === activeLoopToken) { this.advanceInFlight = false; } } } }