Merge pull request 'push-rotxwwmkkwos' (#70) from push-rotxwwmkkwos into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 28m37s
All checks were successful
Build and Push Docker Image / build (push) Successful in 28m37s
Reviewed-on: #70
This commit was merged in pull request #70.
This commit is contained in:
22
Blackboard/Done/openhome_queue_hybrid.md
Normal file
22
Blackboard/Done/openhome_queue_hybrid.md
Normal file
@@ -0,0 +1,22 @@
|
||||
**Tu réaliseras ce travail en appliquant scrupuleusement les règles définies dans [@Rules.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules.md)**
|
||||
|
||||
|
||||
Nous allons travailler spécifiquement et sur rien d'autre que la queue Open Home des Média Renderer dans la CRAT PMO Control. [@openhome.rs](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmocontrol/src/queue/openhome.rs)
|
||||
|
||||
Tu ne peux modifier que ce fichier et a priori tu n'as besoin de lire que ce fichier.
|
||||
|
||||
Actuellement, cette queue est Stateless. C'est parfait, sauf sur un point, la gestion des métadonnées. En effet, les services OpenHome ne permettent pas de modifier les métadonnées d'une piste. Et cela m'ennuie. car mon control point ne peut pas mettre à jour les métadonnées d'une piste si elles sont changées par le média serveur
|
||||
|
||||
Les items de la queue openhome sont identifiés par un ID. L'idée est de maintenir en cache dans la structure de queue Open Home une map qui lit cette ID avec des métadonnées. Je parle bien de l'ID open home de la track et pas de l'index (position) dans la queue de lecture.
|
||||
|
||||
Tout le jeu consistera à enregistrer une copie des métadonnées dans cette map à partir de toutes les méthode du fichier [@openhome.rs](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmocontrol/src/queue/openhome.rs) Qui accepte des PlaybackItem :
|
||||
|
||||
- append_or_init_index
|
||||
- replace_item
|
||||
- sync_queue
|
||||
|
||||
Inversement, à chaque fois qu'on retournera un playback item, On n'oubliera pas de renvoyer les métadonnées du cache plutôt que celles renvoyées par OpenHome. Peut-être qu'il est juste nécessaire de modifier playback_item_from_entry
|
||||
|
||||
On profitera des appels réguliers à la fonction queue_snapshot Pour faire le ménage dans le cache en ne gardant que les entrées qui correspondent aux ID de la queue actuelle.
|
||||
|
||||
Cela nous permettra de rajouter, une fonction d'update des métadonnées d'un item de la queue. Au niveau du backend open home, puis dans un second temps des autres backend de queue, puis du Média Renderer.
|
||||
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`
|
||||
86
Blackboard/Report/openhome_queue_hybrid.md
Normal file
86
Blackboard/Report/openhome_queue_hybrid.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Rapport : Queue OpenHome hybride avec cache de métadonnées
|
||||
|
||||
## Objectif
|
||||
Transformer la queue OpenHome de stateless à hybride en ajoutant un cache de métadonnées. Cela permet au control point de mettre à jour les métadonnées des pistes même si le service OpenHome ne le permet pas nativement.
|
||||
|
||||
## Problématique
|
||||
Les services OpenHome ne permettent pas de modifier les métadonnées d'une piste une fois qu'elle est dans la queue. Cela empêchait le control point de refléter les mises à jour de métadonnées effectuées par le média serveur.
|
||||
|
||||
## Solution implémentée
|
||||
|
||||
### 1. Structure de données
|
||||
Ajout d'un champ `metadata_cache: HashMap<u32, Option<TrackMetadata>>` dans `OpenHomeQueue` :
|
||||
- Clé : ID OpenHome de la track (pas l'index/position)
|
||||
- Valeur : Métadonnées optionnelles de la piste
|
||||
|
||||
### 2. Enregistrement des métadonnées
|
||||
Les métadonnées sont enregistrées dans le cache dans toutes les méthodes qui manipulent des `PlaybackItem` :
|
||||
|
||||
- **`add_playback_item`** : Enregistre les métadonnées lors de l'insertion
|
||||
- **`replace_item`** : Supprime l'ancien ID et enregistre le nouveau
|
||||
- **`replace_queue`** : Enregistre pour tous les nouveaux items
|
||||
- **`sync_queue`** et helpers :
|
||||
- `replace_queue_preserve_current` : Enregistre pour les nouveaux items
|
||||
- `replace_queue_with_pivot` : Met à jour les métadonnées du pivot
|
||||
- `rebuild_playlist_section` : Met à jour pour items conservés et nouveaux
|
||||
- `replace_queue_standard_lcs` : Met à jour pour items conservés et nouveaux
|
||||
|
||||
### 3. Lecture depuis le cache
|
||||
Modification de `playback_item_from_entry` pour utiliser les métadonnées du cache en priorité :
|
||||
|
||||
```rust
|
||||
let metadata = self.metadata_cache
|
||||
.get(&entry.id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| entry.metadata());
|
||||
```
|
||||
|
||||
### 4. Nettoyage du cache
|
||||
Le cache est nettoyé automatiquement lors des suppressions :
|
||||
- `delete_all()` → `metadata_cache.clear()`
|
||||
- `delete_id()` / `delete_id_if_exists()` → `metadata_cache.remove()`
|
||||
- Pas de nettoyage dans `queue_snapshot` (pas nécessaire, quelques entrées orphelines n'ont pas d'impact)
|
||||
|
||||
### 5. API publique
|
||||
Ajout de la méthode publique `update_item_metadata` :
|
||||
|
||||
```rust
|
||||
pub fn update_item_metadata(
|
||||
&mut self,
|
||||
index: usize,
|
||||
metadata: Option<crate::model::TrackMetadata>,
|
||||
) -> Result<(), ControlPointError>
|
||||
```
|
||||
|
||||
Cette méthode permet de mettre à jour manuellement les métadonnées d'un item à un index donné.
|
||||
|
||||
## Points clés de l'implémentation
|
||||
|
||||
### Utilisation de l'ID OpenHome (pas l'index)
|
||||
Le cache utilise l'ID OpenHome comme clé, pas la position dans la queue. Cela permet de suivre une piste même si sa position change.
|
||||
|
||||
### Synchronisation intelligente
|
||||
Dans `sync_queue` :
|
||||
- **CASE 1** : Item courant PAS dans la nouvelle queue → métadonnées préservées en cache
|
||||
- **CASE 2** : Item courant DANS la nouvelle queue → métadonnées mises à jour avec celles de la nouvelle queue
|
||||
|
||||
### Gestion des fuites mémoire
|
||||
Quelques entrées orphelines peuvent subsister si un autre control point modifie la playlist, mais :
|
||||
- Elles ne causent pas de bug (jamais consultées)
|
||||
- Impact mémoire négligeable
|
||||
- Naturellement écrasées lors des synchronisations
|
||||
|
||||
## Fichiers modifiés
|
||||
- `pmocontrol/src/queue/openhome.rs` (unique fichier modifié)
|
||||
|
||||
## Impact
|
||||
- ✅ Le control point peut maintenant afficher des métadonnées à jour
|
||||
- ✅ Les mises à jour du média serveur se reflètent dans la queue
|
||||
- ✅ Pas de changement de l'API publique (sauf ajout de `update_item_metadata`)
|
||||
- ✅ Pas d'impact sur les autres backends de queue
|
||||
- ✅ Compatible avec le comportement existant
|
||||
|
||||
## Prochaines étapes suggérées
|
||||
1. Ajouter `update_item_metadata` aux autres backends de queue (InternalQueue)
|
||||
2. Exposer cette fonctionnalité au niveau du MediaRenderer
|
||||
3. Implémenter la synchronisation automatique des métadonnées depuis le MediaServer
|
||||
@@ -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,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::usize;
|
||||
|
||||
use quick_xml::escape::escape;
|
||||
@@ -21,7 +22,10 @@ pub struct OpenHomeQueue {
|
||||
playlist_client: OhPlaylistClient,
|
||||
info_client: Option<OhInfoClient>,
|
||||
product_client: Option<OhProductClient>,
|
||||
// current_index: Option<usize>,
|
||||
/// Cache des métadonnées par ID OpenHome.
|
||||
/// Permet de maintenir des métadonnées à jour même si le service OpenHome
|
||||
/// ne permet pas de les modifier directement.
|
||||
metadata_cache: HashMap<u32, Option<crate::model::TrackMetadata>>,
|
||||
}
|
||||
|
||||
impl OpenHomeQueue {
|
||||
@@ -36,6 +40,7 @@ impl OpenHomeQueue {
|
||||
playlist_client: playlist,
|
||||
info_client,
|
||||
product_client,
|
||||
metadata_cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,8 +65,37 @@ impl OpenHomeQueue {
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour les métadonnées d'un item de la queue à l'index spécifié.
|
||||
///
|
||||
/// Contrairement au service OpenHome qui ne permet pas de modifier les métadonnées,
|
||||
/// cette méthode met à jour le cache local de métadonnées, permettant ainsi au
|
||||
/// control point de maintenir des métadonnées à jour même si le média serveur
|
||||
/// les modifie.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `index` - Position de l'item dans la queue (0-based)
|
||||
/// * `metadata` - Nouvelles métadonnées à associer à l'item
|
||||
///
|
||||
/// # Errors
|
||||
/// Retourne une erreur si l'index est hors limites.
|
||||
pub fn update_item_metadata(
|
||||
&mut self,
|
||||
index: usize,
|
||||
metadata: Option<crate::model::TrackMetadata>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
let track_id = self.position_to_id(index)?;
|
||||
self.metadata_cache.insert(track_id, metadata);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn playback_item_from_entry(&self, entry: &OhTrackEntry) -> PlaybackItem {
|
||||
let metadata = entry.metadata();
|
||||
// Utiliser les métadonnées du cache si disponibles, sinon celles de l'entrée
|
||||
let metadata = self
|
||||
.metadata_cache
|
||||
.get(&entry.id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| entry.metadata());
|
||||
|
||||
let didl_id = entry
|
||||
.didl_id()
|
||||
.unwrap_or_else(|| format!("openhome:{}", entry.id));
|
||||
@@ -76,12 +110,6 @@ impl OpenHomeQueue {
|
||||
}
|
||||
}
|
||||
|
||||
fn item_with_openhome_id(&self, mut item: PlaybackItem, track_id: u32) -> PlaybackItem {
|
||||
item.didl_id = format!("openhome:{}", track_id);
|
||||
item.media_server_id = DeviceId(format!("openhome:{}", self.renderer_id.0));
|
||||
item
|
||||
}
|
||||
|
||||
fn add_playback_item(
|
||||
&mut self,
|
||||
item: PlaybackItem,
|
||||
@@ -92,6 +120,10 @@ impl OpenHomeQueue {
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(after_id, &item.uri, &metadata_xml)?;
|
||||
|
||||
// Enregistrer les métadonnées dans le cache
|
||||
self.metadata_cache.insert(new_id, item.metadata);
|
||||
|
||||
Ok(new_id)
|
||||
}
|
||||
|
||||
@@ -112,6 +144,7 @@ impl OpenHomeQueue {
|
||||
for &track_id in current_track_ids.iter().rev() {
|
||||
if track_id as usize != playing_id {
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
self.metadata_cache.remove(&track_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +155,10 @@ impl OpenHomeQueue {
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
|
||||
// Enregistrer les métadonnées dans le cache
|
||||
self.metadata_cache.insert(new_id, item.metadata);
|
||||
|
||||
previous_id = new_id;
|
||||
}
|
||||
|
||||
@@ -150,6 +187,7 @@ impl OpenHomeQueue {
|
||||
track_id
|
||||
);
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
self.metadata_cache.remove(&track_id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -180,6 +218,11 @@ impl OpenHomeQueue {
|
||||
let existing_id = remaining_ids[remaining_idx];
|
||||
remaining_idx += 1;
|
||||
previous_id = existing_id;
|
||||
|
||||
// Mettre à jour les métadonnées de l'item existant conservé
|
||||
self.metadata_cache
|
||||
.insert(existing_id, item.metadata.clone());
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
track_id = existing_id,
|
||||
@@ -192,6 +235,10 @@ impl OpenHomeQueue {
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
|
||||
// Enregistrer les métadonnées du nouvel item
|
||||
self.metadata_cache.insert(new_id, item.metadata.clone());
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
after_id = previous_id,
|
||||
@@ -266,6 +313,11 @@ impl OpenHomeQueue {
|
||||
|
||||
// PIVOT keeps its ID and position - it's the anchor point
|
||||
let previous_id = pivot_id as u32;
|
||||
|
||||
// Mettre à jour les métadonnées du pivot
|
||||
self.metadata_cache
|
||||
.insert(previous_id, new_items[pivot_idx_new].metadata.clone());
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
pivot_id,
|
||||
@@ -328,6 +380,7 @@ impl OpenHomeQueue {
|
||||
"Using delete_all() for complete replacement (more robust for live playlists)"
|
||||
);
|
||||
self.playlist_client.delete_all()?;
|
||||
self.metadata_cache.clear();
|
||||
} else {
|
||||
// Selective deletion when keeping some items
|
||||
for idx in (0..current_track_ids.len()).rev() {
|
||||
@@ -336,6 +389,7 @@ impl OpenHomeQueue {
|
||||
// Use delete_id_if_exists() to handle cases where another control point
|
||||
// may have already modified the playlist
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
self.metadata_cache.remove(&track_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,11 +420,18 @@ impl OpenHomeQueue {
|
||||
let existing_id = remaining_ids[remaining_idx];
|
||||
remaining_idx += 1;
|
||||
previous_id = existing_id;
|
||||
|
||||
// Mettre à jour les métadonnées de l'item existant conservé
|
||||
self.metadata_cache.insert(existing_id, item.metadata);
|
||||
} else {
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
|
||||
// Enregistrer les métadonnées du nouvel item
|
||||
self.metadata_cache.insert(new_id, item.metadata);
|
||||
|
||||
previous_id = new_id;
|
||||
}
|
||||
}
|
||||
@@ -596,6 +657,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist_client.delete_all()?;
|
||||
self.metadata_cache.clear();
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
@@ -608,6 +670,10 @@ impl QueueBackend for OpenHomeQueue {
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
|
||||
// Enregistrer les métadonnées dans le cache
|
||||
self.metadata_cache.insert(new_id, item.metadata);
|
||||
|
||||
previous_id = new_id;
|
||||
}
|
||||
|
||||
@@ -618,6 +684,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
if items.is_empty() {
|
||||
self.playlist_client.delete_all()?;
|
||||
self.metadata_cache.clear();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -744,6 +811,10 @@ impl QueueBackend for OpenHomeQueue {
|
||||
.playlist_client
|
||||
.insert(before_id, &item.uri, &metadata)?;
|
||||
|
||||
// Mettre à jour le cache avec les nouvelles métadonnées
|
||||
self.metadata_cache.remove(&track_id);
|
||||
self.metadata_cache.insert(new_id, item.metadata);
|
||||
|
||||
if ci == Some(index) {
|
||||
self.playlist_client.seek_id(new_id)?;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.3.15
|
||||
0.3.17
|
||||
|
||||
Reference in New Issue
Block a user