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
|
- **Action** : LLM implémente la tâche
|
||||||
- **Output** : Fichier `Report/{nom}.md` (même nom obligatoire)
|
- **Output** : Fichier `Report/{nom}.md` (même nom obligatoire)
|
||||||
- **Contenu du rapport** :
|
- **Contenu du rapport** :
|
||||||
- Résumé du travail effectué
|
- Résumé **court** du travail effectué (2-3 phrases maximum)
|
||||||
- Liste des fichiers créés/modifiés
|
- Liste **exhaustive** des fichiers créés/modifiés avec leur chemin complet
|
||||||
- **INTERDIT** : Rapport détaillé dans la discussion (uniquement dans `Report/`)
|
- **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)
|
#### 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]]
|
[[package]]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.15"
|
version = "0.3.17"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum 0.8.7",
|
"axum 0.8.7",
|
||||||
"console-subscriber",
|
"console-subscriber",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.15"
|
version = "0.3.17"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed } from "vue";
|
||||||
import type { ContainerEntry } from "@/services/pmocontrol/types";
|
import type { ContainerEntry } from "@/services/pmocontrol/types";
|
||||||
import { Folder, Music } from "lucide-vue-next";
|
import { Folder, Music } from "lucide-vue-next";
|
||||||
import ActionMenu from "./ActionMenu.vue";
|
import ActionMenu from "./ActionMenu.vue";
|
||||||
|
import { useCoverImage } from "@/composables/useCoverImage";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entry: ContainerEntry;
|
entry: ContainerEntry;
|
||||||
@@ -16,18 +17,16 @@ const emit = defineEmits<{
|
|||||||
addToQueue: [containerId: string, rendererId: string];
|
addToQueue: [containerId: string, rendererId: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Track image loading state
|
// Use the new cover image composable
|
||||||
const imageLoaded = ref(false);
|
const albumArtUri = computed(() => props.entry.album_art_uri);
|
||||||
const imageError = ref(false);
|
const {
|
||||||
|
imageLoaded,
|
||||||
// Reset image state when album_art_uri changes
|
imageError,
|
||||||
watch(
|
coverImageRef,
|
||||||
() => props.entry.album_art_uri,
|
cacheBustedUrl,
|
||||||
() => {
|
handleImageLoad,
|
||||||
imageLoaded.value = false;
|
handleImageError,
|
||||||
imageError.value = false;
|
} = useCoverImage(albumArtUri);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const iconComponent = computed(() => {
|
const iconComponent = computed(() => {
|
||||||
const cls = props.entry.class.toLowerCase();
|
const cls = props.entry.class.toLowerCase();
|
||||||
@@ -61,15 +60,6 @@ function handlePlayNow(rendererId: string) {
|
|||||||
function handleAddToQueue(rendererId: string) {
|
function handleAddToQueue(rendererId: string) {
|
||||||
emit("addToQueue", props.entry.id, rendererId);
|
emit("addToQueue", props.entry.id, rendererId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleImageLoad() {
|
|
||||||
imageLoaded.value = true;
|
|
||||||
imageError.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImageError() {
|
|
||||||
imageError.value = true;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -79,17 +69,29 @@ function handleImageError() {
|
|||||||
<!-- Cover avec icône de type en overlay -->
|
<!-- Cover avec icône de type en overlay -->
|
||||||
<div class="container-cover">
|
<div class="container-cover">
|
||||||
<img
|
<img
|
||||||
v-if="entry.album_art_uri && !imageError"
|
ref="coverImageRef"
|
||||||
v-show="imageLoaded"
|
:style="{
|
||||||
:src="entry.album_art_uri"
|
opacity:
|
||||||
|
cacheBustedUrl && imageLoaded && !imageError
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
visibility:
|
||||||
|
cacheBustedUrl && imageLoaded && !imageError
|
||||||
|
? 'visible'
|
||||||
|
: 'hidden',
|
||||||
|
position:
|
||||||
|
cacheBustedUrl && imageLoaded && !imageError
|
||||||
|
? 'relative'
|
||||||
|
: 'absolute',
|
||||||
|
}"
|
||||||
|
:src="cacheBustedUrl || ''"
|
||||||
:alt="entry.title"
|
:alt="entry.title"
|
||||||
class="cover-image"
|
class="cover-image"
|
||||||
loading="lazy"
|
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="!entry.album_art_uri || imageError || !imageLoaded"
|
v-show="!cacheBustedUrl || imageError || !imageLoaded"
|
||||||
class="cover-placeholder"
|
class="cover-placeholder"
|
||||||
>
|
>
|
||||||
<component :is="iconComponent" :size="28" />
|
<component :is="iconComponent" :size="28" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<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 { useRenderer } from "@/composables/useRenderers";
|
||||||
|
import { useCoverImage } from "@/composables/useCoverImage";
|
||||||
import { useUIStore } from "@/stores/ui";
|
import { useUIStore } from "@/stores/ui";
|
||||||
import { api } from "@/services/pmocontrol/api";
|
import { api } from "@/services/pmocontrol/api";
|
||||||
import { Music, X } from "lucide-vue-next";
|
import { Music, X } from "lucide-vue-next";
|
||||||
@@ -21,12 +22,16 @@ const seekTargetMs = ref<number | null>(null);
|
|||||||
const showCoverOverlay = ref(false);
|
const showCoverOverlay = ref(false);
|
||||||
const showMetadata = ref(false);
|
const showMetadata = ref(false);
|
||||||
|
|
||||||
// Track image loading state
|
// Use the new cover image composable
|
||||||
const imageLoaded = ref(false);
|
const albumArtUri = computed(() => metadata.value?.album_art_uri);
|
||||||
const imageError = ref(false);
|
const {
|
||||||
|
imageLoaded,
|
||||||
// Reference to the image element
|
imageError,
|
||||||
const coverImageRef = ref<HTMLImageElement | null>(null);
|
coverImageRef,
|
||||||
|
cacheBustedUrl,
|
||||||
|
handleImageLoad,
|
||||||
|
handleImageError,
|
||||||
|
} = useCoverImage(albumArtUri);
|
||||||
|
|
||||||
function openCoverOverlay() {
|
function openCoverOverlay() {
|
||||||
if (hasCover.value) {
|
if (hasCover.value) {
|
||||||
@@ -99,44 +104,6 @@ const hasCover = computed(
|
|||||||
() => !!metadata.value?.album_art_uri && !imageError.value,
|
() => !!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
|
// Calculer le pourcentage à partir d'une position X dans la barre
|
||||||
function calculateProgressFromX(
|
function calculateProgressFromX(
|
||||||
clientX: number,
|
clientX: number,
|
||||||
@@ -382,17 +349,26 @@ const swipeOpacity = computed(() => {
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
ref="coverImageRef"
|
ref="coverImageRef"
|
||||||
v-if="metadata?.album_art_uri && !imageError"
|
:style="{
|
||||||
v-show="imageLoaded"
|
opacity:
|
||||||
:src="metadata.album_art_uri"
|
cacheBustedUrl && imageLoaded && !imageError ? 1 : 0,
|
||||||
|
visibility:
|
||||||
|
cacheBustedUrl && imageLoaded && !imageError
|
||||||
|
? 'visible'
|
||||||
|
: 'hidden',
|
||||||
|
position:
|
||||||
|
cacheBustedUrl && imageLoaded && !imageError
|
||||||
|
? 'relative'
|
||||||
|
: 'absolute',
|
||||||
|
}"
|
||||||
|
:src="cacheBustedUrl || ''"
|
||||||
:alt="metadata?.album || 'Album cover'"
|
:alt="metadata?.album || 'Album cover'"
|
||||||
class="cover-image"
|
class="cover-image"
|
||||||
loading="lazy"
|
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="!metadata?.album_art_uri || imageError || !imageLoaded"
|
v-show="!cacheBustedUrl || imageError || !imageLoaded"
|
||||||
class="cover-placeholder"
|
class="cover-placeholder"
|
||||||
>
|
>
|
||||||
<Music :size="64" />
|
<Music :size="64" />
|
||||||
@@ -456,8 +432,8 @@ const swipeOpacity = computed(() => {
|
|||||||
<X :size="24" />
|
<X :size="24" />
|
||||||
</button>
|
</button>
|
||||||
<img
|
<img
|
||||||
v-if="hasCover"
|
v-if="hasCover && cacheBustedUrl"
|
||||||
:src="metadata?.album_art_uri!"
|
:src="cacheBustedUrl"
|
||||||
:alt="metadata?.album || 'Album cover'"
|
:alt="metadata?.album || 'Album cover'"
|
||||||
class="cover-overlay-image"
|
class="cover-overlay-image"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, nextTick } from "vue";
|
import { computed } from "vue";
|
||||||
import type { ContainerEntry } from "@/services/pmocontrol/types";
|
import type { ContainerEntry } from "@/services/pmocontrol/types";
|
||||||
import { Music } from "lucide-vue-next";
|
import { Music } from "lucide-vue-next";
|
||||||
import ActionMenu from "./ActionMenu.vue";
|
import ActionMenu from "./ActionMenu.vue";
|
||||||
|
import { useCoverImage } from "@/composables/useCoverImage";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entry: ContainerEntry;
|
entry: ContainerEntry;
|
||||||
@@ -15,50 +16,16 @@ const emit = defineEmits<{
|
|||||||
addToQueue: [itemId: string, rendererId: string];
|
addToQueue: [itemId: string, rendererId: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Track image loading state
|
// Use the new cover image composable
|
||||||
const imageLoaded = ref(false);
|
const albumArtUri = computed(() => props.entry.album_art_uri);
|
||||||
const imageError = ref(false);
|
const {
|
||||||
|
imageLoaded,
|
||||||
// Reference to the image element
|
imageError,
|
||||||
const coverImageRef = ref<HTMLImageElement | null>(null);
|
coverImageRef,
|
||||||
|
cacheBustedUrl,
|
||||||
// Check if image is already loaded (cached images may load synchronously)
|
handleImageLoad,
|
||||||
function checkImageComplete() {
|
handleImageError,
|
||||||
nextTick(() => {
|
} = useCoverImage(albumArtUri);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePlayNow(rendererId: string) {
|
function handlePlayNow(rendererId: string) {
|
||||||
emit("playNow", props.entry.id, rendererId);
|
emit("playNow", props.entry.id, rendererId);
|
||||||
@@ -75,17 +42,26 @@ function handleAddToQueue(rendererId: string) {
|
|||||||
<div class="media-cover">
|
<div class="media-cover">
|
||||||
<img
|
<img
|
||||||
ref="coverImageRef"
|
ref="coverImageRef"
|
||||||
v-if="entry.album_art_uri && !imageError"
|
:style="{
|
||||||
v-show="imageLoaded"
|
opacity:
|
||||||
:src="entry.album_art_uri"
|
cacheBustedUrl && imageLoaded && !imageError ? 1 : 0,
|
||||||
|
visibility:
|
||||||
|
cacheBustedUrl && imageLoaded && !imageError
|
||||||
|
? 'visible'
|
||||||
|
: 'hidden',
|
||||||
|
position:
|
||||||
|
cacheBustedUrl && imageLoaded && !imageError
|
||||||
|
? 'relative'
|
||||||
|
: 'absolute',
|
||||||
|
}"
|
||||||
|
:src="cacheBustedUrl || ''"
|
||||||
:alt="entry.album || entry.title"
|
:alt="entry.album || entry.title"
|
||||||
class="cover-image"
|
class="cover-image"
|
||||||
loading="lazy"
|
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="!entry.album_art_uri || imageError || !imageLoaded"
|
v-show="!cacheBustedUrl || imageError || !imageLoaded"
|
||||||
class="cover-placeholder"
|
class="cover-placeholder"
|
||||||
>
|
>
|
||||||
<Music :size="20" />
|
<Music :size="20" />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, nextTick } from "vue";
|
import { computed } from "vue";
|
||||||
import { Music, Play } from "lucide-vue-next";
|
import { Music, Play } from "lucide-vue-next";
|
||||||
import type { QueueItem } from "@/services/pmocontrol/types";
|
import type { QueueItem } from "@/services/pmocontrol/types";
|
||||||
|
import { useCoverImage } from "@/composables/useCoverImage";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
item: QueueItem;
|
item: QueueItem;
|
||||||
@@ -12,50 +13,16 @@ const emit = defineEmits<{
|
|||||||
click: [item: QueueItem];
|
click: [item: QueueItem];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Track image loading state
|
// Use the new cover image composable
|
||||||
const imageLoaded = ref(false);
|
const albumArtUri = computed(() => props.item.album_art_uri);
|
||||||
const imageError = ref(false);
|
const {
|
||||||
|
imageLoaded,
|
||||||
// Reference to the image element
|
imageError,
|
||||||
const coverImageRef = ref<HTMLImageElement | null>(null);
|
coverImageRef,
|
||||||
|
cacheBustedUrl,
|
||||||
// Check if image is already loaded (cached images may load synchronously)
|
handleImageLoad,
|
||||||
function checkImageComplete() {
|
handleImageError,
|
||||||
nextTick(() => {
|
} = useCoverImage(albumArtUri);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClick(item: QueueItem) {
|
function handleClick(item: QueueItem) {
|
||||||
console.log("[QueueItem] Click detected on item:", item.index, item.title);
|
console.log("[QueueItem] Click detected on item:", item.index, item.title);
|
||||||
@@ -80,17 +47,26 @@ function handleClick(item: QueueItem) {
|
|||||||
<div class="item-cover">
|
<div class="item-cover">
|
||||||
<img
|
<img
|
||||||
ref="coverImageRef"
|
ref="coverImageRef"
|
||||||
v-if="item.album_art_uri && !imageError"
|
:style="{
|
||||||
v-show="imageLoaded"
|
opacity:
|
||||||
:src="item.album_art_uri"
|
cacheBustedUrl && imageLoaded && !imageError ? 1 : 0,
|
||||||
|
visibility:
|
||||||
|
cacheBustedUrl && imageLoaded && !imageError
|
||||||
|
? 'visible'
|
||||||
|
: 'hidden',
|
||||||
|
position:
|
||||||
|
cacheBustedUrl && imageLoaded && !imageError
|
||||||
|
? 'relative'
|
||||||
|
: 'absolute',
|
||||||
|
}"
|
||||||
|
:src="cacheBustedUrl || ''"
|
||||||
:alt="item.album || 'Album cover'"
|
:alt="item.album || 'Album cover'"
|
||||||
class="cover-image"
|
class="cover-image"
|
||||||
loading="lazy"
|
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
/>
|
/>
|
||||||
<Music
|
<Music
|
||||||
v-if="!item.album_art_uri || imageError || !imageLoaded"
|
v-show="!cacheBustedUrl || imageError || !imageLoaded"
|
||||||
:size="20"
|
:size="20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted, nextTick } from "vue";
|
import { computed } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import type {
|
import type {
|
||||||
RendererCapabilitiesSummary,
|
RendererCapabilitiesSummary,
|
||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
} from "@/services/pmocontrol/types";
|
} from "@/services/pmocontrol/types";
|
||||||
import StatusBadge from "./StatusBadge.vue";
|
import StatusBadge from "./StatusBadge.vue";
|
||||||
import { Music, Volume2, VolumeX } from "lucide-vue-next";
|
import { Music, Volume2, VolumeX } from "lucide-vue-next";
|
||||||
|
import { useCoverImage } from "@/composables/useCoverImage";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
renderer: RendererSummary;
|
renderer: RendererSummary;
|
||||||
@@ -19,50 +20,16 @@ const router = useRouter();
|
|||||||
// Métadonnées proviennent directement de l'état du renderer (API + SSE)
|
// Métadonnées proviennent directement de l'état du renderer (API + SSE)
|
||||||
const metadata = computed(() => props.state?.current_track);
|
const metadata = computed(() => props.state?.current_track);
|
||||||
|
|
||||||
// Track image loading state
|
// Use the new cover image composable
|
||||||
const imageLoaded = ref(false);
|
const albumArtUri = computed(() => metadata.value?.album_art_uri);
|
||||||
const imageError = ref(false);
|
const {
|
||||||
|
imageLoaded,
|
||||||
// Reference to the image element
|
imageError,
|
||||||
const coverImageRef = ref<HTMLImageElement | null>(null);
|
coverImageRef,
|
||||||
|
cacheBustedUrl,
|
||||||
// Check if image is already loaded (cached images may load synchronously)
|
handleImageLoad,
|
||||||
function checkImageComplete() {
|
handleImageError,
|
||||||
nextTick(() => {
|
} = useCoverImage(albumArtUri);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const protocolLabel = computed(() => {
|
const protocolLabel = computed(() => {
|
||||||
switch (props.renderer.protocol) {
|
switch (props.renderer.protocol) {
|
||||||
@@ -148,16 +115,27 @@ function goToRenderer() {
|
|||||||
<div class="card-cover">
|
<div class="card-cover">
|
||||||
<img
|
<img
|
||||||
ref="coverImageRef"
|
ref="coverImageRef"
|
||||||
v-if="hasCover"
|
:style="{
|
||||||
v-show="imageLoaded"
|
opacity: hasCover && cacheBustedUrl && imageLoaded ? 1 : 0,
|
||||||
:src="metadata?.album_art_uri!"
|
visibility:
|
||||||
|
hasCover && cacheBustedUrl && imageLoaded
|
||||||
|
? 'visible'
|
||||||
|
: 'hidden',
|
||||||
|
position:
|
||||||
|
hasCover && cacheBustedUrl && imageLoaded
|
||||||
|
? 'relative'
|
||||||
|
: 'absolute',
|
||||||
|
}"
|
||||||
|
:src="cacheBustedUrl || ''"
|
||||||
:alt="metadata?.album || 'Album cover'"
|
:alt="metadata?.album || 'Album cover'"
|
||||||
class="cover-image"
|
class="cover-image"
|
||||||
loading="lazy"
|
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
/>
|
/>
|
||||||
<div v-if="!hasCover || !imageLoaded" class="cover-placeholder">
|
<div
|
||||||
|
v-show="!cacheBustedUrl || !imageLoaded"
|
||||||
|
class="cover-placeholder"
|
||||||
|
>
|
||||||
<Music :size="48" />
|
<Music :size="48" />
|
||||||
</div>
|
</div>
|
||||||
</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