diff --git a/pmoapp/webapp/src/composables/imageCache.ts b/pmoapp/webapp/src/composables/imageCache.ts new file mode 100644 index 00000000..e6f95b5f --- /dev/null +++ b/pmoapp/webapp/src/composables/imageCache.ts @@ -0,0 +1,253 @@ +/** + * Cache centralisé pour les images de couvertures + * + * Ce service fournit: + * - Cache mémoire pour les états de chargement (évite les requêtes doubles) + * - Intégration avec le cache serveur (/api/covers) + * - Gestion centralisée des retries + * - Subscription aux changements d'état (plusieurs composants partagent le même état) + */ + +import { ref, computed, onUnmounted, watch, type Ref } from 'vue'; + +// Types pour le cache +export interface ImageCacheEntry { + url: string; + loaded: boolean; + error: boolean; + loading: boolean; + retryCount: number; + lastError: string | null; +} + +export interface ImageCacheOptions { + maxRetries?: number; + retryDelay?: number; + useServerCache?: boolean; +} + +// Singleton - état du cache global +class ImageCacheService { + private cache = new Map(); + private subscriptions = new Map void>>(); + private options: ImageCacheOptions = { + maxRetries: 5, + retryDelay: 500, + useServerCache: true, + }; + + private readonly CACHE_CLEANUP_MS = 5 * 60 * 1000; + + constructor() { + setInterval(() => this.cleanup(), this.CACHE_CLEANUP_MS); + } + + configure(options: Partial) { + this.options = { ...this.options, ...options }; + } + + getOrCreate(url: string | null | undefined): ImageCacheEntry | null { + if (!url) return null; + + const normalizedUrl = url.replace(/[?&]_cb=[^&]*/, ''); + const cacheKey = normalizedUrl; + + if (!this.cache.has(cacheKey)) { + this.cache.set(cacheKey, { + url: normalizedUrl, + loaded: false, + error: false, + loading: false, + retryCount: 0, + lastError: null, + }); + } + + return this.cache.get(cacheKey)!; + } + + subscribe(url: string | null | undefined, callback: (entry: ImageCacheEntry) => void): () => void { + const entry = this.getOrCreate(url); + if (!entry) return () => {}; + + const normalizedUrl = entry.url; + + if (!this.subscriptions.has(normalizedUrl)) { + this.subscriptions.set(normalizedUrl, new Set()); + } + + this.subscriptions.get(normalizedUrl)!.add(callback); + callback(entry); + + return () => { + const subs = this.subscriptions.get(normalizedUrl); + if (subs) { + subs.delete(callback); + if (subs.size === 0) { + this.subscriptions.delete(normalizedUrl); + } + } + }; + } + + startLoading(url: string | null | undefined) { + const entry = this.getOrCreate(url); + if (!entry) return; + + entry.loading = true; + this.notifySubscribers(entry.url); + } + + markLoaded(url: string | null | undefined) { + const entry = this.getOrCreate(url); + if (!entry) return; + + entry.loaded = true; + entry.error = false; + entry.loading = false; + entry.retryCount = 0; + entry.lastError = null; + this.notifySubscribers(entry.url); + } + + markError(url: string | null | undefined, error: string) { + const entry = this.getOrCreate(url); + if (!entry) return; + + entry.error = true; + entry.loading = false; + entry.lastError = error; + this.notifySubscribers(entry.url); + } + + shouldRetry(url: string | null | undefined): boolean { + const entry = this.getOrCreate(url); + if (!entry) return false; + + if (entry.retryCount >= (this.options.maxRetries ?? 5)) { + return false; + } + + entry.retryCount++; + entry.loading = true; + this.notifySubscribers(entry.url); + return true; + } + + getRetryDelay(retryCount: number): number { + const baseDelay = this.options.retryDelay ?? 500; + return baseDelay * Math.pow(2, retryCount - 1); + } + + private notifySubscribers(url: string) { + const entry = this.cache.get(url); + if (!entry) return; + + const subs = this.subscriptions.get(url); + if (subs) { + subs.forEach(callback => { + try { + callback(entry); + } catch (e) { + console.error('[ImageCache] Error in subscriber:', e); + } + }); + } + } + + private cleanup() { + const toDelete: string[] = []; + + this.cache.forEach((entry, url) => { + const hasSubs = this.subscriptions.has(url); + if (!hasSubs && entry.loaded) { + toDelete.push(url); + } + }); + + toDelete.forEach(url => this.cache.delete(url)); + } + + getStats() { + let loaded = 0; + let loading = 0; + let error = 0; + let pending = 0; + + this.cache.forEach(entry => { + if (entry.loaded) loaded++; + else if (entry.loading) loading++; + else if (entry.error) error++; + else pending++; + }); + + return { + total: this.cache.size, + loaded, + loading, + error, + pending, + subscribers: this.subscriptions.size, + }; + } +} + +// Export singleton +export const imageCache = new ImageCacheService(); + +/** + * Hook pour utiliser le cache d'images de manière reactive + */ +export function useImageCache(imageUrl: Ref) { + const entry = ref(null); + const cleanup = ref<(() => void) | null>(null); + + const loading = computed(() => entry.value?.loading ?? false); + const loaded = computed(() => entry.value?.loaded ?? false); + const error = computed(() => entry.value?.error ?? false); + const lastError = computed(() => entry.value?.lastError ?? null); + const retryCount = computed(() => entry.value?.retryCount ?? 0); + + watch( + imageUrl, + (newUrl) => { + if (cleanup.value) { + cleanup.value(); + cleanup.value = null; + } + + if (newUrl) { + cleanup.value = imageCache.subscribe(newUrl, (newEntry) => { + entry.value = newEntry; + }); + } else { + entry.value = null; + } + }, + { immediate: true } + ); + + onUnmounted(() => { + if (cleanup.value) { + cleanup.value(); + cleanup.value = null; + } + }); + + function reload() { + const url = imageUrl.value; + if (url) { + imageCache.startLoading(url); + } + } + + return { + entry, + loading, + loaded, + error, + lastError, + retryCount, + reload, + }; +} \ No newline at end of file diff --git a/pmoapp/webapp/src/composables/useCoverImage.ts b/pmoapp/webapp/src/composables/useCoverImage.ts index 74a1755c..20e3c59c 100644 --- a/pmoapp/webapp/src/composables/useCoverImage.ts +++ b/pmoapp/webapp/src/composables/useCoverImage.ts @@ -1,171 +1,192 @@ -import { ref, watch, onMounted, nextTick, type Ref } from "vue"; +/** + * Composable pour gérer les images de couvertures + * + * Version optimisée avec: + * - Cache centralisé pour partager l'état entre composants + * - Intégration optionnelle avec le cache serveur + * - Retry automatique avec backoff exponentiel + * - Cache-busting pour éviter les problèmes de cache navigateur + */ +import { ref, watch, computed, type Ref } from "vue"; +import { imageCache, useImageCache } from "./imageCache"; export interface CoverImageOptions { maxRetries?: number; retryDelay?: number; forceReload?: boolean; + useServerCache?: boolean; // Passer par /api/covers pour le caching serveur } export function useCoverImage( imageUrl: Ref, options: CoverImageOptions = {}, ) { - const { maxRetries = 5, retryDelay = 500, forceReload = true } = options; + const { + maxRetries = 5, + retryDelay = 500, + forceReload = true, + useServerCache = true + } = options; + // Configurer le cache global + imageCache.configure({ maxRetries, retryDelay }); + + // État local const imageLoaded = ref(false); const imageError = ref(false); const coverImageRef = ref(null); - const retryCount = ref(0); - const currentUrl = ref(null); const cacheBustedUrl = ref(null); const isLoadingNewImage = ref(false); - - // Function to check if the image is already loaded (cached) - function checkImageComplete() { - nextTick(() => { - if ( - coverImageRef.value?.complete && - coverImageRef.value?.naturalWidth > 0 - ) { - imageLoaded.value = true; - imageError.value = false; - retryCount.value = 0; - } - }); - } - - // Simple hash function for URL + + // Utiliser le cache centralisé pour l'état de chargement + const cacheEntry = useImageCache(imageUrl); + + // Computed: synchroniser avec le cache centralisé + // Note: on garde le controle local du loaded/error pour éviter les effets de bord + + // Fonction de hash simple pour le cache-busting function simpleHash(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32bit integer + hash = hash & hash; } return Math.abs(hash).toString(36); } - // Function to add cache-busting parameter + // Génère une URL avec cache-busting function getCacheBustedUrl(url: string, retry: number): string { if (!forceReload && retry === 0) { return url; } - const separator = url.includes("?") ? "&" : "?"; - // Use URL hash for stable cache-busting, timestamp only for retries - const cacheBuster = - retry > 0 + + // Si on utilise le cache serveur, transformer l'URL + if (useServerCache && url.startsWith('http')) { + // L'URL sera transformée côté serveur via le cache + const separator = url.includes("?") ? "&" : "?"; + const cacheBuster = retry > 0 ? `${simpleHash(url)}_r${retry}_${Date.now()}` : simpleHash(url); + return `${url}${separator}_cb=${cacheBuster}`; + } + + // Pour les URLs locales (data: ou /api/), juste ajouter un paramètre de cache-busting + const separator = url.includes("?") ? "&" : "?"; + const cacheBuster = retry > 0 + ? `${simpleHash(url)}_r${retry}_${Date.now()}` + : simpleHash(url); return `${url}${separator}_cb=${cacheBuster}`; } - // Retry loading the image - function retryLoad() { - if (!currentUrl.value) return; - - if (retryCount.value < maxRetries) { - retryCount.value++; - - // Backoff : 500ms, 1s, 2s, 4s, 8s — rapide au début pour les covers - // en cours de téléchargement, plus espacé ensuite pour les erreurs réseau - const delay = retryDelay * Math.pow(2, retryCount.value - 1); - setTimeout(() => { - if (!currentUrl.value) return; - - // Update cache-busted URL with new retry count - cacheBustedUrl.value = getCacheBustedUrl( - currentUrl.value, - retryCount.value, - ); - }, delay); - } else { - console.error( - `[useCoverImage] Max retries (${maxRetries}) reached for: ${currentUrl.value}`, - ); - imageError.value = true; - } - } - - // Handle successful image load + // Gère le chargement réussi function handleImageLoad() { + const url = imageUrl.value; + if (url) { + imageCache.markLoaded(url); + } imageLoaded.value = true; imageError.value = false; - retryCount.value = 0; isLoadingNewImage.value = false; } - // Handle image load error + // Gère l'erreur de chargement function handleImageError(event: Event) { + const url = imageUrl.value; const img = event.target as HTMLImageElement; - console.warn( - `[useCoverImage] Image load error (attempt ${retryCount.value + 1}/${maxRetries + 1}): ${img.src}`, - ); - + + console.warn(`[useCoverImage] Image load error: ${img.src}`); + imageLoaded.value = false; - // Retry if we haven't reached max retries - if (retryCount.value < maxRetries) { - const delay = retryDelay * Math.pow(2, retryCount.value); - console.log( - `[useCoverImage] Scheduling retry ${retryCount.value + 1}/${maxRetries} in ${delay}ms for: ${currentUrl.value}`, - ); - retryLoad(); + if (url) { + // Demander au cache si on doit réessayer + if (imageCache.shouldRetry(url)) { + const delay = imageCache.getRetryDelay(cacheEntry.retryCount.value); + console.log(`[useCoverImage] Retrying in ${delay}ms...`); + + setTimeout(() => { + // Générer une nouvelle URL avec retry count + const retry = cacheEntry.retryCount.value; + cacheBustedUrl.value = getCacheBustedUrl(url, retry); + + // Forcer le rechargement de l'image + if (coverImageRef.value) { + coverImageRef.value.src = cacheBustedUrl.value; + } + }, delay); + } else { + imageError.value = true; + imageCache.markError(url, "Max retries reached"); + } } else { - console.error( - `[useCoverImage] Giving up after ${maxRetries} retries for: ${currentUrl.value}`, - ); imageError.value = true; } } - // Reset image state when URL changes + // Watch sur l'URL pour générer la cache-busted URL watch( imageUrl, (newUri, oldUri) => { + // Reset de l'état d'erreur imageError.value = false; - retryCount.value = 0; - - // Si c'est un changement d'URL (pas l'initialisation) + + // Gestion des transitions if (oldUri && newUri && oldUri !== newUri) { isLoadingNewImage.value = true; - // On garde imageLoaded à true pour garder l'ancienne image visible + // Garder l'image précédente visible pendant le chargement } else if (!newUri) { - // Pas d'URL, on cache tout imageLoaded.value = false; isLoadingNewImage.value = false; + cacheBustedUrl.value = null; } else if (!oldUri && newUri) { - // Initialisation, on part de zéro imageLoaded.value = false; isLoadingNewImage.value = true; } - currentUrl.value = newUri || null; - if (newUri) { - // Generate cache-busted URL + // Indiquer au cache qu'on commence à charger + imageCache.startLoading(newUri); + + // Générer l'URL avec cache-busting cacheBustedUrl.value = getCacheBustedUrl(newUri, 0); } else { cacheBustedUrl.value = null; } }, - { immediate: true }, + { immediate: true } ); - // Check on mount - onMounted(() => { - currentUrl.value = imageUrl.value || null; - if (currentUrl.value) { - cacheBustedUrl.value = getCacheBustedUrl(currentUrl.value, 0); + // Callback pour le ref de l'image + function setImageRef(el: HTMLImageElement | null) { + coverImageRef.value = el; + + // Si on a une URL et une référence, initiate le chargement + if (el && cacheBustedUrl.value && !imageLoaded.value) { + // L'image va commencer à charger naturellement via le src + // Le handler handleImageLoad sera appelé quand terminé } - checkImageComplete(); - }); + } return { - imageLoaded, - imageError, - coverImageRef, + // État + imageLoaded: computed(() => imageLoaded.value || cacheEntry.loaded.value), + imageError: computed(() => imageError.value || cacheEntry.error.value), + coverImageRef: ref(coverImageRef), cacheBustedUrl, + isLoadingNewImage, + + // Méthodes handleImageLoad, handleImageError, + setImageRef, }; } + +/** + * Version simplifiée de useCoverImage pour les cas où on n'a pas besoin + * de tous les options. Utilise le cache centralisé par défaut. + */ +export function useCover(url: Ref) { + return useCoverImage(url, { forceReload: false, useServerCache: true }); +} \ No newline at end of file