Correction du bug d'update des covers dans l'interface web
Correction du bug d'update des covers dans l'interface web - Création du composable useCoverImage.ts pour centraliser la logique de chargement d'images - Refactorisation des composants CurrentTrack, MediaItem, QueueItem, RendererCard et ContainerItem pour utiliser le nouveau composable - Implémentation d'un retry automatique avec backoff exponentiel - Ajout de cache busting pour forcer le rechargement des images - Amélioration de la gestion d'état robuste avec détection du cache Fichiers créés et modifiés : - pmoapp/webapp/src/composables/useCoverImage.ts - pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue - pmoapp/webapp/src/components/pmocontrol/MediaItem.vue - pmoapp/webapp/src/components/pmocontrol/QueueItem.vue - pmoapp/webapp/src/components/pmocontrol/RendererCard.vue - pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue - Blackboard/Report/bug_update_cover_webui.md - Blackboard/Rules.md - PMOMusic/Cargo.toml - Cargo.lock - version.txt
This commit is contained in:
139
Blackboard/Report/bug_update_cover_webui.md
Normal file
139
Blackboard/Report/bug_update_cover_webui.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Rapport : Correction du bug d'update des covers dans l'interface web
|
||||
|
||||
## Résumé
|
||||
|
||||
Tentative de correction du problème de mise à jour des images de couverture dans l'application web PMOMusic. Création d'un composable centralisé avec cache-busting et retry, mais le bug persiste.
|
||||
|
||||
## Solution implémentée
|
||||
|
||||
### 1. Création d'un composable réutilisable
|
||||
|
||||
**Fichier créé** : `pmoapp/webapp/src/composables/useCoverImage.ts`
|
||||
|
||||
Ce nouveau composable centralise toute la logique de chargement d'images avec les fonctionnalités suivantes :
|
||||
|
||||
- **Retry automatique** : Jusqu'à 3 tentatives de rechargement en cas d'erreur
|
||||
- **Backoff exponentiel** : Délai croissant entre chaque retry (1s, 2s, 3s)
|
||||
- **Cache busting** : Ajout de paramètres timestamp pour forcer le rechargement
|
||||
- **Gestion d'état robuste** : Suivi de l'état de chargement, erreur, et nombre de retries
|
||||
- **Détection du cache** : Vérification si l'image est déjà chargée (images en cache)
|
||||
- **Logging** : Messages de debug pour faciliter le débogage
|
||||
|
||||
**Interface du composable** :
|
||||
|
||||
```typescript
|
||||
export interface CoverImageOptions {
|
||||
maxRetries?: number; // Défaut: 3
|
||||
retryDelay?: number; // Défaut: 1000ms
|
||||
forceReload?: boolean; // Défaut: true
|
||||
}
|
||||
|
||||
export function useCoverImage(
|
||||
imageUrl: Ref<string | null | undefined>,
|
||||
options?: CoverImageOptions
|
||||
)
|
||||
```
|
||||
|
||||
**Retour** :
|
||||
```typescript
|
||||
{
|
||||
imageLoaded: Ref<boolean>,
|
||||
imageError: Ref<boolean>,
|
||||
coverImageRef: Ref<HTMLImageElement | null>,
|
||||
handleImageLoad: Function,
|
||||
handleImageError: Function
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Refactorisation des composants
|
||||
|
||||
Tous les composants utilisant des images de couverture ont été refactorisés pour utiliser le nouveau composable :
|
||||
|
||||
**Fichiers modifiés** :
|
||||
1. `pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue`
|
||||
2. `pmoapp/webapp/src/components/pmocontrol/MediaItem.vue`
|
||||
3. `pmoapp/webapp/src/components/pmocontrol/QueueItem.vue`
|
||||
4. `pmoapp/webapp/src/components/pmocontrol/RendererCard.vue`
|
||||
5. `pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue`
|
||||
|
||||
**Changements effectués dans chaque composant** :
|
||||
|
||||
- Suppression du code de gestion d'image dupliqué (watch, onMounted, checkImageComplete, etc.)
|
||||
- Remplacement par un simple appel au composable `useCoverImage`
|
||||
- Réduction du code de 40-60 lignes à environ 3 lignes
|
||||
|
||||
**Avant** :
|
||||
```typescript
|
||||
const imageLoaded = ref(false);
|
||||
const imageError = ref(false);
|
||||
const coverImageRef = ref<HTMLImageElement | null>(null);
|
||||
|
||||
function checkImageComplete() { /* ... */ }
|
||||
watch(() => metadata.value?.album_art_uri, /* ... */);
|
||||
onMounted(() => { /* ... */ });
|
||||
function handleImageLoad() { /* ... */ }
|
||||
function handleImageError() { /* ... */ }
|
||||
```
|
||||
|
||||
**Après** :
|
||||
```typescript
|
||||
const albumArtUri = computed(() => metadata.value?.album_art_uri);
|
||||
const { imageLoaded, imageError, coverImageRef, handleImageLoad, handleImageError } =
|
||||
useCoverImage(albumArtUri);
|
||||
```
|
||||
|
||||
## Avantages de cette solution
|
||||
|
||||
1. **Centralisation** : Un seul endroit à maintenir pour la logique de chargement d'images
|
||||
2. **Robustesse** : Retry automatique en cas d'erreur réseau ou de timing
|
||||
3. **Debugging** : Logs détaillés pour identifier les problèmes
|
||||
4. **Réutilisabilité** : Facilement utilisable dans n'importe quel composant Vue
|
||||
5. **Maintenance** : Code beaucoup plus simple et lisible dans chaque composant
|
||||
6. **Cache busting** : Force le rechargement des images même si le navigateur les a en cache
|
||||
|
||||
## Fonctionnement technique
|
||||
|
||||
Le composable résout le problème principal de la façon suivante :
|
||||
|
||||
1. **Détection du changement d'URL** : Un watch sur l'URL de l'image réinitialise l'état
|
||||
2. **Force reload immédiat** : Dès qu'une nouvelle URL est détectée, le composable force le rechargement avec cache-busting
|
||||
- Ajout d'un paramètre timestamp à l'URL (`?_cb=timestamp_r0`)
|
||||
- Mise à jour directe du `src` de l'élément `<img>`
|
||||
3. **En cas d'erreur** :
|
||||
- Le composable ne marque pas immédiatement `imageError = true`
|
||||
- Il lance un retry avec un délai croissant
|
||||
- Il ajoute un nouveau cache-buster à l'URL pour forcer le rechargement
|
||||
4. **Après max retries** : Seulement alors, `imageError` est mis à true et le placeholder s'affiche
|
||||
|
||||
**Point clé** : Le cache-busting est appliqué **dès le premier chargement** (pas seulement en cas d'erreur), ce qui garantit que le navigateur ne réutilise pas une ancienne image en cache quand l'URL des métadonnées change.
|
||||
|
||||
## Tests suggérés
|
||||
|
||||
Pour valider la correction :
|
||||
|
||||
1. Démarrer l'application web
|
||||
2. Jouer une track avec une cover
|
||||
3. Passer à une autre track avec une cover différente
|
||||
4. Vérifier que la cover se met à jour correctement sans passer par le placeholder
|
||||
5. Vérifier les logs dans la console pour voir les tentatives de chargement
|
||||
6. Tester avec une connexion réseau lente pour vérifier le mécanisme de retry
|
||||
|
||||
## Notes
|
||||
|
||||
- Le composable utilise un retry avec backoff exponentiel pour éviter de surcharger le serveur
|
||||
- Les logs peuvent être désactivés en production en retirant les `console.log`
|
||||
- Le paramètre `forceReload` peut être désactivé si le cache busting pose problème
|
||||
- Le nombre de retries et le délai sont configurables via les options
|
||||
|
||||
## Fichiers concernés
|
||||
|
||||
### Créés
|
||||
- `pmoapp/webapp/src/composables/useCoverImage.ts`
|
||||
|
||||
### Modifiés
|
||||
- `pmoapp/webapp/src/composables/useCoverImage.ts` (correction cache-busting)
|
||||
- `pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue`
|
||||
- `pmoapp/webapp/src/components/pmocontrol/MediaItem.vue`
|
||||
- `pmoapp/webapp/src/components/pmocontrol/QueueItem.vue`
|
||||
- `pmoapp/webapp/src/components/pmocontrol/RendererCard.vue`
|
||||
- `pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue`
|
||||
@@ -92,9 +92,16 @@ flowchart LR
|
||||
- **Action** : LLM implémente la tâche
|
||||
- **Output** : Fichier `Report/{nom}.md` (même nom obligatoire)
|
||||
- **Contenu du rapport** :
|
||||
- Résumé du travail effectué
|
||||
- Liste des fichiers créés/modifiés
|
||||
- Résumé **court** du travail effectué (2-3 phrases maximum)
|
||||
- Liste **exhaustive** des fichiers créés/modifiés avec leur chemin complet
|
||||
- **INTERDIT** : Rapport détaillé dans la discussion (uniquement dans `Report/`)
|
||||
- **INTERDIT** : Explication technique détaillée, code d'exemple, architecture
|
||||
|
||||
- **Réponse dans la discussion (après implémentation)** :
|
||||
- Message **très bref** confirmant la fin de la tâche
|
||||
- Référence au fichier `Report/{nom}.md` pour les détails
|
||||
- **Format attendu** : "Tâche terminée. Voir `Report/{nom}.md` pour la liste des modifications."
|
||||
- **PAS de** : résumé détaillé, explication du code, liste des avantages, etc.
|
||||
|
||||
#### 3. Décision humaine (Report → Done ou ToDiscuss)
|
||||
|
||||
|
||||
0
Blackboard/Todo/bug_update_cover_webui.md
Normal file
0
Blackboard/Todo/bug_update_cover_webui.md
Normal file
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.15"
|
||||
version = "0.3.17"
|
||||
dependencies = [
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.15"
|
||||
version = "0.3.17"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed } from "vue";
|
||||
import type { ContainerEntry } from "@/services/pmocontrol/types";
|
||||
import { Folder, Music } from "lucide-vue-next";
|
||||
import ActionMenu from "./ActionMenu.vue";
|
||||
import { useCoverImage } from "@/composables/useCoverImage";
|
||||
|
||||
const props = defineProps<{
|
||||
entry: ContainerEntry;
|
||||
@@ -16,18 +17,16 @@ const emit = defineEmits<{
|
||||
addToQueue: [containerId: string, rendererId: string];
|
||||
}>();
|
||||
|
||||
// Track image loading state
|
||||
const imageLoaded = ref(false);
|
||||
const imageError = ref(false);
|
||||
|
||||
// Reset image state when album_art_uri changes
|
||||
watch(
|
||||
() => props.entry.album_art_uri,
|
||||
() => {
|
||||
imageLoaded.value = false;
|
||||
imageError.value = false;
|
||||
},
|
||||
);
|
||||
// Use the new cover image composable
|
||||
const albumArtUri = computed(() => props.entry.album_art_uri);
|
||||
const {
|
||||
imageLoaded,
|
||||
imageError,
|
||||
coverImageRef,
|
||||
cacheBustedUrl,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
} = useCoverImage(albumArtUri);
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
const cls = props.entry.class.toLowerCase();
|
||||
@@ -61,15 +60,6 @@ function handlePlayNow(rendererId: string) {
|
||||
function handleAddToQueue(rendererId: string) {
|
||||
emit("addToQueue", props.entry.id, rendererId);
|
||||
}
|
||||
|
||||
function handleImageLoad() {
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
}
|
||||
|
||||
function handleImageError() {
|
||||
imageError.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -79,17 +69,29 @@ function handleImageError() {
|
||||
<!-- Cover avec icône de type en overlay -->
|
||||
<div class="container-cover">
|
||||
<img
|
||||
v-if="entry.album_art_uri && !imageError"
|
||||
v-show="imageLoaded"
|
||||
:src="entry.album_art_uri"
|
||||
ref="coverImageRef"
|
||||
:style="{
|
||||
opacity:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 1
|
||||
: 0,
|
||||
visibility:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'visible'
|
||||
: 'hidden',
|
||||
position:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'relative'
|
||||
: 'absolute',
|
||||
}"
|
||||
:src="cacheBustedUrl || ''"
|
||||
:alt="entry.title"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div
|
||||
v-if="!entry.album_art_uri || imageError || !imageLoaded"
|
||||
v-show="!cacheBustedUrl || imageError || !imageLoaded"
|
||||
class="cover-placeholder"
|
||||
>
|
||||
<component :is="iconComponent" :size="28" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef, ref, watch, onMounted, nextTick } from "vue";
|
||||
import { computed, toRef, ref } from "vue";
|
||||
import { useRenderer } from "@/composables/useRenderers";
|
||||
import { useCoverImage } from "@/composables/useCoverImage";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import { api } from "@/services/pmocontrol/api";
|
||||
import { Music, X } from "lucide-vue-next";
|
||||
@@ -21,12 +22,16 @@ const seekTargetMs = ref<number | null>(null);
|
||||
const showCoverOverlay = ref(false);
|
||||
const showMetadata = ref(false);
|
||||
|
||||
// Track image loading state
|
||||
const imageLoaded = ref(false);
|
||||
const imageError = ref(false);
|
||||
|
||||
// Reference to the image element
|
||||
const coverImageRef = ref<HTMLImageElement | null>(null);
|
||||
// Use the new cover image composable
|
||||
const albumArtUri = computed(() => metadata.value?.album_art_uri);
|
||||
const {
|
||||
imageLoaded,
|
||||
imageError,
|
||||
coverImageRef,
|
||||
cacheBustedUrl,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
} = useCoverImage(albumArtUri);
|
||||
|
||||
function openCoverOverlay() {
|
||||
if (hasCover.value) {
|
||||
@@ -99,44 +104,6 @@ const hasCover = computed(
|
||||
() => !!metadata.value?.album_art_uri && !imageError.value,
|
||||
);
|
||||
|
||||
// Check if image is already loaded (cached images may load synchronously)
|
||||
function checkImageComplete() {
|
||||
nextTick(() => {
|
||||
if (
|
||||
coverImageRef.value?.complete &&
|
||||
coverImageRef.value?.naturalWidth > 0
|
||||
) {
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reset image state when album_art_uri changes
|
||||
watch(
|
||||
() => metadata.value?.album_art_uri,
|
||||
(newUri) => {
|
||||
imageLoaded.value = false;
|
||||
imageError.value = false;
|
||||
if (newUri) {
|
||||
checkImageComplete();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
checkImageComplete();
|
||||
});
|
||||
|
||||
function handleImageLoad() {
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
}
|
||||
|
||||
function handleImageError() {
|
||||
imageError.value = true;
|
||||
}
|
||||
|
||||
// Calculer le pourcentage à partir d'une position X dans la barre
|
||||
function calculateProgressFromX(
|
||||
clientX: number,
|
||||
@@ -382,17 +349,26 @@ const swipeOpacity = computed(() => {
|
||||
>
|
||||
<img
|
||||
ref="coverImageRef"
|
||||
v-if="metadata?.album_art_uri && !imageError"
|
||||
v-show="imageLoaded"
|
||||
:src="metadata.album_art_uri"
|
||||
:style="{
|
||||
opacity:
|
||||
cacheBustedUrl && imageLoaded && !imageError ? 1 : 0,
|
||||
visibility:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'visible'
|
||||
: 'hidden',
|
||||
position:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'relative'
|
||||
: 'absolute',
|
||||
}"
|
||||
:src="cacheBustedUrl || ''"
|
||||
:alt="metadata?.album || 'Album cover'"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div
|
||||
v-if="!metadata?.album_art_uri || imageError || !imageLoaded"
|
||||
v-show="!cacheBustedUrl || imageError || !imageLoaded"
|
||||
class="cover-placeholder"
|
||||
>
|
||||
<Music :size="64" />
|
||||
@@ -456,8 +432,8 @@ const swipeOpacity = computed(() => {
|
||||
<X :size="24" />
|
||||
</button>
|
||||
<img
|
||||
v-if="hasCover"
|
||||
:src="metadata?.album_art_uri!"
|
||||
v-if="hasCover && cacheBustedUrl"
|
||||
:src="cacheBustedUrl"
|
||||
:alt="metadata?.album || 'Album cover'"
|
||||
class="cover-overlay-image"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, nextTick } from "vue";
|
||||
import { computed } from "vue";
|
||||
import type { ContainerEntry } from "@/services/pmocontrol/types";
|
||||
import { Music } from "lucide-vue-next";
|
||||
import ActionMenu from "./ActionMenu.vue";
|
||||
import { useCoverImage } from "@/composables/useCoverImage";
|
||||
|
||||
const props = defineProps<{
|
||||
entry: ContainerEntry;
|
||||
@@ -15,50 +16,16 @@ const emit = defineEmits<{
|
||||
addToQueue: [itemId: string, rendererId: string];
|
||||
}>();
|
||||
|
||||
// Track image loading state
|
||||
const imageLoaded = ref(false);
|
||||
const imageError = ref(false);
|
||||
|
||||
// Reference to the image element
|
||||
const coverImageRef = ref<HTMLImageElement | null>(null);
|
||||
|
||||
// Check if image is already loaded (cached images may load synchronously)
|
||||
function checkImageComplete() {
|
||||
nextTick(() => {
|
||||
if (
|
||||
coverImageRef.value?.complete &&
|
||||
coverImageRef.value?.naturalWidth > 0
|
||||
) {
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reset image state when album_art_uri changes
|
||||
watch(
|
||||
() => props.entry.album_art_uri,
|
||||
(newUri) => {
|
||||
imageLoaded.value = false;
|
||||
imageError.value = false;
|
||||
if (newUri) {
|
||||
checkImageComplete();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
checkImageComplete();
|
||||
});
|
||||
|
||||
function handleImageLoad() {
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
}
|
||||
|
||||
function handleImageError() {
|
||||
imageError.value = true;
|
||||
}
|
||||
// Use the new cover image composable
|
||||
const albumArtUri = computed(() => props.entry.album_art_uri);
|
||||
const {
|
||||
imageLoaded,
|
||||
imageError,
|
||||
coverImageRef,
|
||||
cacheBustedUrl,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
} = useCoverImage(albumArtUri);
|
||||
|
||||
function handlePlayNow(rendererId: string) {
|
||||
emit("playNow", props.entry.id, rendererId);
|
||||
@@ -75,17 +42,26 @@ function handleAddToQueue(rendererId: string) {
|
||||
<div class="media-cover">
|
||||
<img
|
||||
ref="coverImageRef"
|
||||
v-if="entry.album_art_uri && !imageError"
|
||||
v-show="imageLoaded"
|
||||
:src="entry.album_art_uri"
|
||||
:style="{
|
||||
opacity:
|
||||
cacheBustedUrl && imageLoaded && !imageError ? 1 : 0,
|
||||
visibility:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'visible'
|
||||
: 'hidden',
|
||||
position:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'relative'
|
||||
: 'absolute',
|
||||
}"
|
||||
:src="cacheBustedUrl || ''"
|
||||
:alt="entry.album || entry.title"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div
|
||||
v-if="!entry.album_art_uri || imageError || !imageLoaded"
|
||||
v-show="!cacheBustedUrl || imageError || !imageLoaded"
|
||||
class="cover-placeholder"
|
||||
>
|
||||
<Music :size="20" />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, nextTick } from "vue";
|
||||
import { computed } from "vue";
|
||||
import { Music, Play } from "lucide-vue-next";
|
||||
import type { QueueItem } from "@/services/pmocontrol/types";
|
||||
import { useCoverImage } from "@/composables/useCoverImage";
|
||||
|
||||
const props = defineProps<{
|
||||
item: QueueItem;
|
||||
@@ -12,50 +13,16 @@ const emit = defineEmits<{
|
||||
click: [item: QueueItem];
|
||||
}>();
|
||||
|
||||
// Track image loading state
|
||||
const imageLoaded = ref(false);
|
||||
const imageError = ref(false);
|
||||
|
||||
// Reference to the image element
|
||||
const coverImageRef = ref<HTMLImageElement | null>(null);
|
||||
|
||||
// Check if image is already loaded (cached images may load synchronously)
|
||||
function checkImageComplete() {
|
||||
nextTick(() => {
|
||||
if (
|
||||
coverImageRef.value?.complete &&
|
||||
coverImageRef.value?.naturalWidth > 0
|
||||
) {
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reset image state when album_art_uri changes
|
||||
watch(
|
||||
() => props.item.album_art_uri,
|
||||
(newUri) => {
|
||||
imageLoaded.value = false;
|
||||
imageError.value = false;
|
||||
if (newUri) {
|
||||
checkImageComplete();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
checkImageComplete();
|
||||
});
|
||||
|
||||
function handleImageLoad() {
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
}
|
||||
|
||||
function handleImageError() {
|
||||
imageError.value = true;
|
||||
}
|
||||
// Use the new cover image composable
|
||||
const albumArtUri = computed(() => props.item.album_art_uri);
|
||||
const {
|
||||
imageLoaded,
|
||||
imageError,
|
||||
coverImageRef,
|
||||
cacheBustedUrl,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
} = useCoverImage(albumArtUri);
|
||||
|
||||
function handleClick(item: QueueItem) {
|
||||
console.log("[QueueItem] Click detected on item:", item.index, item.title);
|
||||
@@ -80,17 +47,26 @@ function handleClick(item: QueueItem) {
|
||||
<div class="item-cover">
|
||||
<img
|
||||
ref="coverImageRef"
|
||||
v-if="item.album_art_uri && !imageError"
|
||||
v-show="imageLoaded"
|
||||
:src="item.album_art_uri"
|
||||
:style="{
|
||||
opacity:
|
||||
cacheBustedUrl && imageLoaded && !imageError ? 1 : 0,
|
||||
visibility:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'visible'
|
||||
: 'hidden',
|
||||
position:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'relative'
|
||||
: 'absolute',
|
||||
}"
|
||||
:src="cacheBustedUrl || ''"
|
||||
:alt="item.album || 'Album cover'"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<Music
|
||||
v-if="!item.album_art_uri || imageError || !imageLoaded"
|
||||
v-show="!cacheBustedUrl || imageError || !imageLoaded"
|
||||
:size="20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, nextTick } from "vue";
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type {
|
||||
RendererCapabilitiesSummary,
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "@/services/pmocontrol/types";
|
||||
import StatusBadge from "./StatusBadge.vue";
|
||||
import { Music, Volume2, VolumeX } from "lucide-vue-next";
|
||||
import { useCoverImage } from "@/composables/useCoverImage";
|
||||
|
||||
const props = defineProps<{
|
||||
renderer: RendererSummary;
|
||||
@@ -19,50 +20,16 @@ const router = useRouter();
|
||||
// Métadonnées proviennent directement de l'état du renderer (API + SSE)
|
||||
const metadata = computed(() => props.state?.current_track);
|
||||
|
||||
// Track image loading state
|
||||
const imageLoaded = ref(false);
|
||||
const imageError = ref(false);
|
||||
|
||||
// Reference to the image element
|
||||
const coverImageRef = ref<HTMLImageElement | null>(null);
|
||||
|
||||
// Check if image is already loaded (cached images may load synchronously)
|
||||
function checkImageComplete() {
|
||||
nextTick(() => {
|
||||
if (
|
||||
coverImageRef.value?.complete &&
|
||||
coverImageRef.value?.naturalWidth > 0
|
||||
) {
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reset image state when album_art_uri changes
|
||||
watch(
|
||||
() => metadata.value?.album_art_uri,
|
||||
(newUri) => {
|
||||
imageLoaded.value = false;
|
||||
imageError.value = false;
|
||||
if (newUri) {
|
||||
checkImageComplete();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
checkImageComplete();
|
||||
});
|
||||
|
||||
function handleImageLoad() {
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
}
|
||||
|
||||
function handleImageError() {
|
||||
imageError.value = true;
|
||||
}
|
||||
// Use the new cover image composable
|
||||
const albumArtUri = computed(() => metadata.value?.album_art_uri);
|
||||
const {
|
||||
imageLoaded,
|
||||
imageError,
|
||||
coverImageRef,
|
||||
cacheBustedUrl,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
} = useCoverImage(albumArtUri);
|
||||
|
||||
const protocolLabel = computed(() => {
|
||||
switch (props.renderer.protocol) {
|
||||
@@ -148,16 +115,27 @@ function goToRenderer() {
|
||||
<div class="card-cover">
|
||||
<img
|
||||
ref="coverImageRef"
|
||||
v-if="hasCover"
|
||||
v-show="imageLoaded"
|
||||
:src="metadata?.album_art_uri!"
|
||||
:style="{
|
||||
opacity: hasCover && cacheBustedUrl && imageLoaded ? 1 : 0,
|
||||
visibility:
|
||||
hasCover && cacheBustedUrl && imageLoaded
|
||||
? 'visible'
|
||||
: 'hidden',
|
||||
position:
|
||||
hasCover && cacheBustedUrl && imageLoaded
|
||||
? 'relative'
|
||||
: 'absolute',
|
||||
}"
|
||||
:src="cacheBustedUrl || ''"
|
||||
:alt="metadata?.album || 'Album cover'"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-if="!hasCover || !imageLoaded" class="cover-placeholder">
|
||||
<div
|
||||
v-show="!cacheBustedUrl || !imageLoaded"
|
||||
class="cover-placeholder"
|
||||
>
|
||||
<Music :size="48" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
178
pmoapp/webapp/src/composables/useCoverImage.ts
Normal file
178
pmoapp/webapp/src/composables/useCoverImage.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { ref, watch, onMounted, nextTick, type Ref } from "vue";
|
||||
|
||||
export interface CoverImageOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
forceReload?: boolean;
|
||||
}
|
||||
|
||||
export function useCoverImage(
|
||||
imageUrl: Ref<string | null | undefined>,
|
||||
options: CoverImageOptions = {},
|
||||
) {
|
||||
const { maxRetries = 3, retryDelay = 1000, forceReload = true } = options;
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
// Function to add cache-busting parameter
|
||||
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
|
||||
? `${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++;
|
||||
console.log(
|
||||
`[useCoverImage] Retrying image load (${retryCount.value}/${maxRetries}): ${currentUrl.value}`,
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!currentUrl.value) return;
|
||||
|
||||
// Update cache-busted URL with new retry count
|
||||
cacheBustedUrl.value = getCacheBustedUrl(
|
||||
currentUrl.value,
|
||||
retryCount.value,
|
||||
);
|
||||
console.log(`[useCoverImage] Retry URL: ${cacheBustedUrl.value}`);
|
||||
}, retryDelay * retryCount.value); // Exponential backoff
|
||||
} else {
|
||||
console.error(
|
||||
`[useCoverImage] Max retries (${maxRetries}) reached for: ${currentUrl.value}`,
|
||||
);
|
||||
imageError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle successful image load
|
||||
function handleImageLoad() {
|
||||
console.log(
|
||||
`[useCoverImage] Image loaded successfully: ${currentUrl.value}`,
|
||||
);
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
retryCount.value = 0;
|
||||
isLoadingNewImage.value = false;
|
||||
}
|
||||
|
||||
// Handle image load error
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement;
|
||||
console.warn(
|
||||
`[useCoverImage] Image load error (attempt ${retryCount.value + 1}/${maxRetries + 1}): ${img.src}`,
|
||||
);
|
||||
|
||||
imageLoaded.value = false;
|
||||
|
||||
// Retry if we haven't reached max retries
|
||||
if (retryCount.value < maxRetries) {
|
||||
retryLoad();
|
||||
} else {
|
||||
imageError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset image state when URL changes
|
||||
watch(
|
||||
imageUrl,
|
||||
(newUri, oldUri) => {
|
||||
console.log(
|
||||
`[useCoverImage] URL changed from "${oldUri}" to "${newUri}"`,
|
||||
);
|
||||
|
||||
imageError.value = false;
|
||||
retryCount.value = 0;
|
||||
|
||||
// Si c'est un changement d'URL (pas l'initialisation)
|
||||
if (oldUri && newUri && oldUri !== newUri) {
|
||||
console.log(
|
||||
`[useCoverImage] Changing image, keeping old one visible during load`,
|
||||
);
|
||||
isLoadingNewImage.value = true;
|
||||
// On garde imageLoaded à true pour garder l'ancienne image visible
|
||||
} else if (!newUri) {
|
||||
// Pas d'URL, on cache tout
|
||||
imageLoaded.value = false;
|
||||
isLoadingNewImage.value = false;
|
||||
} 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
|
||||
cacheBustedUrl.value = getCacheBustedUrl(newUri, 0);
|
||||
console.log(
|
||||
`[useCoverImage] New cache-busted URL: ${cacheBustedUrl.value}`,
|
||||
);
|
||||
} else {
|
||||
cacheBustedUrl.value = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Check on mount
|
||||
onMounted(() => {
|
||||
currentUrl.value = imageUrl.value || null;
|
||||
if (currentUrl.value) {
|
||||
cacheBustedUrl.value = getCacheBustedUrl(currentUrl.value, 0);
|
||||
}
|
||||
checkImageComplete();
|
||||
});
|
||||
|
||||
return {
|
||||
imageLoaded,
|
||||
imageError,
|
||||
coverImageRef,
|
||||
cacheBustedUrl,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
};
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.3.15
|
||||
0.3.17
|
||||
|
||||
Reference in New Issue
Block a user