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:
2026-01-24 18:35:40 +01:00
parent fa03b32c09
commit 98aff899b3
12 changed files with 466 additions and 234 deletions

View File

@@ -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" />

View File

@@ -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"
/>

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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>