import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' import { preloadImage } from '../utils/preloadImage.js' // Buffered producer/consumer so the cascade cadence is decoupled from fetch // latency (operator-flagged 2026-06-04). The OLD pipeline trickled each batch // right after its fetch and bet the next round-trip would finish inside the // ~240ms trickle window; when a fetch ran long (TABLESAMPLE hits random, // sometimes-cold blocks; RTT jitter) the animation starved and the view // "burped" out a clump of images. Now: // - PRODUCER (_fill): races ahead fetching batches into `queue` up to a // target depth, refilling whenever the queue dips below BUFFER_MIN. It // also kicks off the image PRELOAD for each queued item so decoding // pipelines ahead of the reveal. // - CONSUMER (_drain): pops ONE item, WAITS for its thumbnail to be fully // decoded, then reveals it — at most one per CADENCE_MS. Because the // producer preloads ahead, the decode-wait is usually already satisfied, // so the reveal stays evenly paced without idling the network. // The decode-gate is what makes the showcase's signature cascade land each // tile fully-loaded (the flip-in animates a real image, never a gray // placeholder); the single-item reveal keeps it strictly one-at-a-time // (operator-flagged 2026-06-04). The very first image still waits on the first // fetch + decode (a cold TABLESAMPLE is a separate, query-side concern); // everything after it is buffer-smoothed. const PAGE = 3 const CADENCE_MS = 160 // floor between fully-loaded reveals (doubled // 2026-06-04 — slower, more deliberate cadence) const PRIME = 6 // items buffered before the drain starts const BUFFER_TARGET = 30 // producer tops the queue up to this const BUFFER_MIN = 12 // ...and refills once the queue dips below this const INITIAL_COUNT = 60 // cascade length on load / shuffle const SCROLL_COUNT = 15 // cascade length per infinite-scroll demand // Showcase is endless by design (random sample); an unlucky all-duplicate // batch must be retried — only a genuinely empty API response is exhaustion. const FETCH_RETRY_CAP = 8 function _sleep(ms) { return new Promise((r) => setTimeout(r, ms)) } export const useShowcaseStore = defineStore('showcase', () => { const api = useApi() const images = ref([]) const loading = ref(false) const error = ref(null) const exhausted = ref(false) const seen = new Set() // Internal buffer (not reactive — the consumer is what feeds the UI via // images.value). let queue = [] // id -> Promise that settles when the thumbnail is paint-ready. Started by // the producer so decoding runs ahead of the reveal; awaited by the consumer // so no tile is shown before its image is loaded. let _preloads = new Map() // Sequence token: shuffle / re-mount bumps it so in-flight producers and // the drain bail instead of interleaving two runs into one images list. let _seq = 0 let _filling = false let _draining = false function _preload(item) { if (!_preloads.has(item.id)) _preloads.set(item.id, preloadImage(item.thumbnail_url)) return _preloads.get(item.id) } // One batch, retried while the random sample comes back all-duplicates. // Returns the fresh items, or null when the API is genuinely empty. async function _fetchFresh(mySeq) { for (let attempt = 0; attempt < FETCH_RETRY_CAP; attempt++) { if (mySeq !== _seq) return [] const body = await api .get('/api/showcase', { params: { limit: PAGE } }) .catch((e) => { error.value = error.value || (e.message || String(e)) return null }) if (mySeq !== _seq) return [] const items = (body && body.images) || [] if (items.length === 0) return null const fresh = items.filter((i) => !seen.has(i.id)) fresh.forEach((f) => seen.add(f.id)) if (fresh.length) return fresh } return null // retry cap hit → tiny library, treat as exhausted } // Producer: top the buffer up to `target`. Single-flight. async function _fill(mySeq, target) { if (_filling) return _filling = true try { while (mySeq === _seq && !exhausted.value && queue.length < target) { const batch = await _fetchFresh(mySeq) if (mySeq !== _seq) return if (batch === null) { exhausted.value = true; return } queue.push(...batch) batch.forEach(_preload) // pipeline decoding ahead of the reveal } } finally { _filling = false } } // Consumer: show `count` items at a fixed cadence, topping up the buffer as // it drains. Single-flight so the initial cascade and scroll appends can't // interleave. async function _drain(mySeq, count) { if (_draining) return _draining = true loading.value = true try { for (let shown = 0; shown < count && mySeq === _seq; shown++) { if (!exhausted.value && queue.length < BUFFER_MIN) { _fill(mySeq, BUFFER_TARGET) // topup, no await } let guard = 0 while (queue.length === 0 && !exhausted.value && mySeq === _seq) { await _sleep(20) if (++guard > 500) break // 10s starvation safety net } if (mySeq !== _seq) return if (queue.length === 0) return // exhausted + empty const item = queue.shift() await _preload(item) // reveal only once the thumbnail is fully decoded if (mySeq !== _seq) return images.value.push(item) await _sleep(CADENCE_MS) } } finally { if (mySeq === _seq) { _draining = false loading.value = false } } } async function loadInitial() { _seq += 1 const mySeq = _seq images.value = [] queue = [] _preloads = new Map() seen.clear() exhausted.value = false error.value = null _filling = false _draining = false loading.value = true try { // Prime a small buffer before the cascade starts so it doesn't starve // at the front; the drain's own topup grows it to BUFFER_TARGET. await _fill(mySeq, PRIME) if (mySeq !== _seq) return if (queue.length === 0 && exhausted.value) return // empty library await _drain(mySeq, INITIAL_COUNT) } finally { if (mySeq === _seq) loading.value = false } } async function fetchPage() { // Infinite-scroll demand — append another cascade of SCROLL_COUNT. if (_draining || exhausted.value) return await _drain(_seq, SCROLL_COUNT) } async function shuffle() { await loadInitial() } const hasMore = computed(() => !exhausted.value) const isEmpty = computed( () => !loading.value && images.value.length === 0 && error.value === null ) return { images, loading, error, hasMore, isEmpty, fetchPage, shuffle, loadInitial } })