Files
archy/aiui/packages/app/src/composables/useBannerFallback.ts
T

138 lines
4.6 KiB
TypeScript
Raw Normal View History

2026-08-12 10:55:50 +00:00
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(''),
() => {
generation++
primaryIndex.value = 0
stage.value = 'primary'
apiUrl.value = null
apiFetching = false
kickoffApiWhenNoPrimary()
},
)
return { bannerSrc, fallbackGradient, onBannerError }
}