core/timers/stage.timer.ts
core/timers/stage.timer.ts is a file in Bane's Lab Social Share. 105 lines of code and 24 definitions.
import type { Disposer } from "@banes-lab/web/types/base.types.ts";
import { EMPTY_SEQUENCE } from "#configuration/strings/card.strings";
import { MILLISECONDS } from "#configuration/constants/card.constants";
import type { Timeline } from "#types/card.types";
export const samplesOf = function samplesOf(timeline: Timeline, rate: number): readonly number[] {
if (timeline.frames <= 1) {
return [0];
}
const span = timeline.loop ? timeline.frames : timeline.frames - 1;
const count = Math.max(2, Math.round((span / timeline.fps) * rate) + (timeline.loop ? 0 : 1));
const step = timeline.loop ? span / count : span / (count - 1);
return Array.from({ length: count }, (_, index) => index * step);
};
export const sampleAt = function sampleAt(elapsed: number, count: number, rate: number, loop: boolean): number {
const raw = Math.floor((Math.max(0, elapsed) * rate) / MILLISECONDS);
return loop ? raw % count : Math.min(raw, count - 1);
};
export const sequenceIndexAt = function sequenceIndexAt(delays: readonly number[], elapsedMs: number): number {
const total = delays.reduce((sum, delay) => sum + delay, 0);
if (delays.length === 0 || total <= 0) {
throw new RangeError(EMPTY_SEQUENCE);
}
let remaining = Math.round(elapsedMs) % total;
for (const [index, delay] of delays.entries()) {
if (remaining < delay) {
return index;
}
remaining -= delay;
}
return delays.length - 1;
};
interface Playback {
busy: boolean;
failed: boolean;
stop: Disposer | null;
}
export const playWhileVisible = function playWhileVisible(
element: Element,
timeline: Timeline,
rate: number,
paint: (frame: number) => Promise<void>,
onFailure: (failure: unknown) => void,
): Disposer {
const state: Playback = { busy: false, failed: false, stop: null };
const pause = (): void => {
state.stop?.();
state.stop = null;
};
const fail = (failure: unknown): void => {
state.failed = true;
pause();
onFailure(failure);
};
const settle = (): void => {
state.busy = false;
};
const play = (): void => {
if (state.stop !== null || state.failed) {
return;
}
state.stop = startClock(timeline, rate, (frame) => {
if (state.busy) {
return;
}
state.busy = true;
paint(frame).then(settle, fail);
});
};
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
play();
} else {
pause();
}
}
});
observer.observe(element);
return () => {
observer.disconnect();
pause();
};
};
export const startClock = function startClock(
timeline: Timeline,
rate: number,
onFrame: (frame: number) => void,
): Disposer {
const samples = samplesOf(timeline, rate);
const started = performance.now();
let shown = -1;
let handle = 0;
const tick = function tick(now: number): void {
const index = sampleAt(now - started, samples.length, rate, timeline.loop);
if (index !== shown) {
shown = index;
onFrame(samples[index] ?? 0);
}
handle = requestAnimationFrame(tick);
};
handle = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(handle);
};
};