138 lines
4.6 KiB
TypeScript
138 lines
4.6 KiB
TypeScript
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<string | null>
|
|
fallbackGradient: ComputedRef<string>
|
|
onBannerError: () => void
|
|
}
|
|
|
|
export function useBannerFallback(options: BannerFallbackOptions): BannerFallbackReturn {
|
|
const primaryIndex = ref(0)
|
|
const stage = ref<'primary' | 'api' | 'done'>('primary')
|
|
const apiUrl = ref<string | null>(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<string | null>(() => {
|
|
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(' |