[feat] Ajout d'un cache centralisé pour les images de couverture
- Nouveau fichier imageCache.ts : service singleton gérant le cache mémoire, les subscriptions et la gestion centralisée des retries - Refonte de useCoverImage.ts pour utiliser le nouveau cache, avec support du caching serveur (/api/covers) et backoff exponentiel - Ajout d'une version simplifiée useCover() pour les cas simples
This commit is contained in:
253
pmoapp/webapp/src/composables/imageCache.ts
Normal file
253
pmoapp/webapp/src/composables/imageCache.ts
Normal file
@@ -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<string, ImageCacheEntry>();
|
||||
private subscriptions = new Map<string, Set<(entry: ImageCacheEntry) => 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<ImageCacheOptions>) {
|
||||
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<string | null | undefined>) {
|
||||
const entry = ref<ImageCacheEntry | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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<string | null | undefined>,
|
||||
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<HTMLImageElement | null>(null);
|
||||
const retryCount = ref(0);
|
||||
const currentUrl = ref<string | null>(null);
|
||||
const cacheBustedUrl = ref<string | null>(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<string | null | undefined>) {
|
||||
return useCoverImage(url, { forceReload: false, useServerCache: true });
|
||||
}
|
||||
Reference in New Issue
Block a user