// A single, persistent banner rotation engine. The rotator and scheduler live here at module // scope, so the rotation keeps running across screen navigations: each screen mounts its own // AdBanner view, which only attaches as the DOM host. This makes the banner continuous and // independent of route transitions (the cycle is not restarted on navigation). import { createBannerRotator, type BannerHost, type Rotator } from './banner'; import type { BannerCampaign, BannerTimings } from './model'; let rotator: Rotator | null = null; let mounted: BannerHost | null = null; let current = ''; let key = ''; // The in-flight horizontal scroll of the current message, so a view mounted by navigation can // resume it from the same offset instead of restarting at the left. Null when not scrolling. let activeScroll: { toPx: number; dur: number; start: number } | null = null; // proxy is the rotator's host: it records the current message + scroll (so a freshly-mounted view // can resume them) and forwards every effect to the currently-attached DOM host, if any. const proxy: BannerHost = { show(md) { current = md; activeScroll = null; mounted?.show(md); }, resetScroll() { activeScroll = null; mounted?.resetScroll(); }, hide(durationMs) { mounted?.hide(durationMs); }, overflowPx() { return mounted?.overflowPx() ?? 0; }, scrollTo(toPx, durationMs) { activeScroll = { toPx, dur: durationMs, start: Date.now() }; mounted?.scrollTo(toPx, durationMs); }, resumeScroll(fromTx, toPx, durationMs) { mounted?.resumeScroll(fromTx, toPx, durationMs); }, }; // bannerKey identifies a campaigns+timings set so configureBanner restarts the cycle only on a // real change, not on every (re)mount. function bannerKey(campaigns: BannerCampaign[], timings: BannerTimings): string { return JSON.stringify({ campaigns, timings }); } /** * configureBanner (re)starts the rotation for the given campaigns and timings, or leaves the * running rotation untouched when they are unchanged — so navigating between screens (which * remounts the view with the same data) continues the cycle rather than restarting it. */ export function configureBanner(campaigns: BannerCampaign[], timings: BannerTimings): void { const k = bannerKey(campaigns, timings); if (k === key && rotator) return; key = k; rotator?.stop(); current = ''; rotator = createBannerRotator(campaigns, proxy, timings); rotator.start(); } /** * bannerCurrent returns the message the engine is currently displaying (empty before the first * one). A freshly-mounted view reads it to render the live message immediately, without a fade — * so navigating between screens does not visibly restart the cycle. */ export function bannerCurrent(): string { return current; } /** * attachBannerHost connects a freshly-mounted view as the DOM host. It does NOT re-show the * current message (the view renders it itself from bannerCurrent on mount): re-showing here would * replay the fade-in on every navigation, which looks like the cycle restarting. The engine's own * timer keeps driving show/hide for real message changes through this host. */ export function attachBannerHost(host: BannerHost): void { mounted = host; // Resume the current message's scroll from where it had reached, so navigation does not restart // a long message at the left. Only while a scroll is still in flight; a finished scroll is left // at its end and the rotator's own loop takes over. if (activeScroll) { const elapsed = Date.now() - activeScroll.start; if (elapsed < activeScroll.dur) { const progress = elapsed / activeScroll.dur; host.resumeScroll(-activeScroll.toPx * progress, activeScroll.toPx, activeScroll.dur - elapsed); } } } /** * remeasureBanner re-presents the current message (re-measuring overflow and restarting its * scroll), for when the viewport size changed (e.g. a portrait↔landscape rotation) and a message * that fit may now overflow, or vice versa. */ export function remeasureBanner(): void { rotator?.restart(); } /** * detachBannerHost disconnects a view on unmount without stopping the engine. It clears the host * only when it is still the current one, so an outgoing view leaving after the incoming view has * already attached does not detach the new host (the transition briefly double-mounts). */ export function detachBannerHost(host: BannerHost): void { if (mounted === host) mounted = null; }