230 lines
6.3 KiB
JavaScript
230 lines
6.3 KiB
JavaScript
function nextAnimationFrame() {
|
|
return new Promise((resolve) => {
|
|
window.requestAnimationFrame(() => resolve());
|
|
});
|
|
}
|
|
|
|
export class CruiseSequencer {
|
|
constructor({
|
|
isActive,
|
|
getItems,
|
|
getItemId,
|
|
focusItem,
|
|
presentItem,
|
|
hideItem,
|
|
clearCurrent,
|
|
onStop,
|
|
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.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;
|
|
}
|
|
|
|
getCurrentItem() {
|
|
if (!this.currentItemId) return null;
|
|
return this.getItems().find((item) => this.getItemId(item) === this.currentItemId) || null;
|
|
}
|
|
|
|
getCurrentItemId() {
|
|
return this.currentItemId;
|
|
}
|
|
|
|
isPresentationPinned() {
|
|
return this.presentationVisible;
|
|
}
|
|
|
|
isBusy() {
|
|
return this.advanceInFlight || this.presentationVisible;
|
|
}
|
|
|
|
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.clearCurrent?.();
|
|
}
|
|
}
|
|
|
|
stop({ preservePresentation = false } = {}) {
|
|
this.interruptPresentation({ preservePresentation });
|
|
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 performAdvance({ interrupt = false } = {}) {
|
|
if (!this.isActive()) return;
|
|
|
|
const items = this.getItems();
|
|
if (!Array.isArray(items) || items.length === 0) return;
|
|
|
|
const targetItem = this.resolveNextItem(items);
|
|
if (!targetItem) return;
|
|
|
|
const token = ++this.sequenceToken;
|
|
const context = this.createContext(token);
|
|
|
|
this.clearTimers();
|
|
this.presentationVisible = false;
|
|
this.clearCurrent?.();
|
|
|
|
this.currentItemId = this.getItemId(targetItem);
|
|
this.currentIndex = items.findIndex(
|
|
(item) => this.getItemId(item) === this.currentItemId,
|
|
);
|
|
|
|
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;
|
|
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();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|