0497394710
- Showcase reveal cadence 80ms -> 160ms (slower, more deliberate one-at-a-time cascade per operator). Bump showcase.spec timer advances to cover 60x160ms. - Gallery filter bar now uses the EXACT gradiated-obsidian frost + blur as TopNav (was a flat rgba(...,0.55) block), so the two read as one continuous piece of chrome with images visibly scrolling under both; the nav's transparent bottom edge against the bar's opaque top leaves a faint seam that separates them at the very top of scroll. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
180 lines
6.8 KiB
JavaScript
180 lines
6.8 KiB
JavaScript
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 }
|
|
})
|