import { ref, computed, watch, type ComputedRef } from 'vue' export interface BannerFallbackOptions { /** Primary image URL candidates, tried in order */ primaryUrls: () => (string | undefined | null)[] /** Async fetch to try when all primary URLs fail */ apiFetch: () => Promise<{ posterUrl: string | null; backdropUrl: string | null }> /** Title for gradient generation */ title: () => string /** Optional seed override for gradient hue */ gradientSeed?: () => string } export interface BannerFallbackReturn { bannerSrc: ComputedRef fallbackGradient: ComputedRef onBannerError: () => void } export function useBannerFallback(options: BannerFallbackOptions): BannerFallbackReturn { const primaryIndex = ref(0) const stage = ref<'primary' | 'api' | 'done'>('primary') const apiUrl = ref(null) let apiFetching = false // Bumped whenever the underlying item changes; a late apiFetch resolution // from a previous item compares against this and is discarded rather than // stamping the old item's artwork onto the new one. let generation = 0 const bannerSrc = computed(() => { if (stage.value === 'done') return null if (stage.value === 'primary') { const urls = options.primaryUrls() // Find first non-null URL starting from primaryIndex for (let i = primaryIndex.value; i < urls.length; i++) { if (urls[i]) return urls[i]! } // No primary URLs available — skip to API immediately return null } if (stage.value === 'api' && apiUrl.value) return apiUrl.value return null }) const fallbackGradient = computed(() => { const seed = options.gradientSeed ? options.gradientSeed() : options.title() const hue = [...seed].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360 return `linear-gradient(135deg, hsl(${hue}, 25%, 12%) 0%, hsl(${(hue + 40) % 360}, 20%, 8%) 100%)` }) async function onBannerError() { if (stage.value === 'primary') { const urls = options.primaryUrls() // Advance to next primary URL let nextIdx = primaryIndex.value + 1 while (nextIdx < urls.length && !urls[nextIdx]) nextIdx++ if (nextIdx < urls.length) { primaryIndex.value = nextIdx return } // All primaries exhausted — try API if (!apiFetching) { apiFetching = true const gen = generation try { const result = await options.apiFetch() // The item changed while the fetch was in flight — this artwork // belongs to the previous item; drop it and let the new item's own // resolution (already restarted by the watcher) stand. if (gen !== generation) return const url = result.backdropUrl ?? result.posterUrl if (url) { apiUrl.value = url stage.value = 'api' return } } catch { /* ignore */ } } stage.value = 'done' return } if (stage.value === 'api') { stage.value = 'done' } } // If no primary URLs at all, trigger API fetch (first render AND after any // item change — see the identity watcher below). function kickoffApiWhenNoPrimary() { const urls = options.primaryUrls() const hasAnyPrimary = urls.some(u => !!u) if (!hasAnyPrimary && !apiFetching) { apiFetching = true const gen = generation options.apiFetch().then(result => { if (gen !== generation) return // stale — item changed mid-flight const url = result.backdropUrl ?? result.posterUrl if (url) { apiUrl.value = url stage.value = 'api' } else { stage.value = 'done' } }).catch(() => { if (gen !== generation) return stage.value = 'done' }) } } kickoffApiWhenNoPrimary() // Detail views are reused, not remounted, when the user selects a different // item in the content window — the props under these getters change but this // composable's state used to survive, so the banner kept showing the // previous item's resolved artwork (stage 'api'/'done' + old apiUrl) forever. // Key the reset on title + the primary URL set: together they identify the // item for every current consumer (Film/TVSeries/Book detail). watch( () => [options.title(), ...options.primaryUrls().map(u => u ?? '')].join(''), () => { generation++ primaryIndex.value = 0 stage.value = 'primary' apiUrl.value = null apiFetching = false kickoffApiWhenNoPrimary() }, ) return { bannerSrc, fallbackGradient, onBannerError } }