Merge pull request 'Fix cache image loading bug in webapp' (#65) from push-sswurqzkvnnx into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 28m8s
All checks were successful
Build and Push Docker Image / build (push) Successful in 28m8s
Reviewed-on: #65
This commit was merged in pull request #65.
This commit is contained in:
134
Blackboard/Report/bug_images_cache_webapp.md
Normal file
134
Blackboard/Report/bug_images_cache_webapp.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# Rapport : Correction du bug d'affichage des images en cache dans la webapp
|
||||||
|
|
||||||
|
## Tâche originale
|
||||||
|
|
||||||
|
Les images de couverture d'album ne s'affichaient plus dans l'application web Control Point. Seuls les logos SVG de fallback étaient visibles, bien que les URLs soient correctes et les images accessibles.
|
||||||
|
|
||||||
|
## Analyse
|
||||||
|
|
||||||
|
### Pattern d'affichage existant
|
||||||
|
|
||||||
|
Les composants utilisaient le pattern suivant :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<img
|
||||||
|
v-if="item.album_art_uri && !imageError"
|
||||||
|
v-show="imageLoaded"
|
||||||
|
:src="item.album_art_uri"
|
||||||
|
@load="handleImageLoad"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
<div v-if="!item.album_art_uri || imageError || !imageLoaded" class="placeholder">
|
||||||
|
<Music :size="20" />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Avec :
|
||||||
|
```typescript
|
||||||
|
const imageLoaded = ref(false);
|
||||||
|
|
||||||
|
watch(() => props.item.album_art_uri, () => {
|
||||||
|
imageLoaded.value = false;
|
||||||
|
imageError.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleImageLoad() {
|
||||||
|
imageLoaded.value = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cause du bug
|
||||||
|
|
||||||
|
Lorsqu'une image est **déjà en cache du navigateur**, elle peut se charger de manière **synchrone** avant que Vue n'ait attaché l'écouteur d'événement `@load`. Dans ce cas :
|
||||||
|
|
||||||
|
1. L'image est créée dans le DOM (via `v-if`)
|
||||||
|
2. Le navigateur charge l'image immédiatement depuis le cache
|
||||||
|
3. L'événement `load` se déclenche **avant** que Vue n'ait attaché `@load`
|
||||||
|
4. `imageLoaded` reste à `false`
|
||||||
|
5. L'image reste cachée par `v-show="imageLoaded"`
|
||||||
|
6. Le placeholder SVG s'affiche à la place
|
||||||
|
|
||||||
|
Ce comportement est particulièrement fréquent avec des images déjà visitées ou après un rechargement de page.
|
||||||
|
|
||||||
|
## Correction appliquée
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
|
||||||
|
Ajout d'une fonction `checkImageComplete()` qui vérifie si l'image est déjà chargée via les propriétés natives de l'élément `<img>` :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const coverImageRef = ref<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
|
function checkImageComplete() {
|
||||||
|
nextTick(() => {
|
||||||
|
if (coverImageRef.value?.complete && coverImageRef.value?.naturalWidth > 0) {
|
||||||
|
imageLoaded.value = true;
|
||||||
|
imageError.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkImageComplete();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => metadata.value?.album_art_uri, (newUri) => {
|
||||||
|
imageLoaded.value = false;
|
||||||
|
imageError.value = false;
|
||||||
|
if (newUri) {
|
||||||
|
checkImageComplete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Et ajout de la référence sur l'élément `<img>` :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<img ref="coverImageRef" ... />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fichiers modifiés
|
||||||
|
|
||||||
|
| Fichier | Modification |
|
||||||
|
|---------|--------------|
|
||||||
|
| `pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue` | Ajout `checkImageComplete()`, `coverImageRef`, `onMounted` |
|
||||||
|
| `pmoapp/webapp/src/components/pmocontrol/QueueItem.vue` | Ajout `checkImageComplete()`, `coverImageRef`, `onMounted` |
|
||||||
|
| `pmoapp/webapp/src/components/pmocontrol/MediaItem.vue` | Ajout `checkImageComplete()`, `coverImageRef`, `onMounted` |
|
||||||
|
| `pmoapp/webapp/src/components/pmocontrol/RendererCard.vue` | Ajout complet de la gestion d'état image (était absent) |
|
||||||
|
|
||||||
|
### Détail des modifications par composant
|
||||||
|
|
||||||
|
#### CurrentTrack.vue
|
||||||
|
- Import de `onMounted`, `nextTick`
|
||||||
|
- Ajout de `coverImageRef`
|
||||||
|
- Ajout de `checkImageComplete()`
|
||||||
|
- Modification du `watch` pour appeler `checkImageComplete()` après changement d'URL
|
||||||
|
- Ajout de `onMounted(() => checkImageComplete())`
|
||||||
|
- Ajout de `ref="coverImageRef"` sur l'élément `<img>`
|
||||||
|
|
||||||
|
#### QueueItem.vue
|
||||||
|
- Mêmes modifications que CurrentTrack.vue
|
||||||
|
|
||||||
|
#### MediaItem.vue
|
||||||
|
- Mêmes modifications que CurrentTrack.vue
|
||||||
|
|
||||||
|
#### RendererCard.vue
|
||||||
|
- Ce composant n'avait pas de gestion d'état de chargement d'image
|
||||||
|
- Ajout complet : `imageLoaded`, `imageError`, `coverImageRef`, `checkImageComplete()`
|
||||||
|
- Ajout des handlers `@load` et `@error`
|
||||||
|
- Modification de `hasCover` pour inclure `!imageError`
|
||||||
|
- Ajout de `v-show="imageLoaded"` sur l'image
|
||||||
|
- Modification de la condition du placeholder
|
||||||
|
|
||||||
|
## Vérification
|
||||||
|
|
||||||
|
- Build webpack réussi sans erreur
|
||||||
|
- Compilation TypeScript OK
|
||||||
|
|
||||||
|
## Remarque technique
|
||||||
|
|
||||||
|
La propriété `HTMLImageElement.complete` retourne `true` si :
|
||||||
|
- L'image a fini de charger (succès ou erreur)
|
||||||
|
- L'attribut `src` est vide ou absent
|
||||||
|
|
||||||
|
C'est pourquoi on vérifie également `naturalWidth > 0` pour s'assurer que l'image a bien été chargée avec succès (une image en erreur a `naturalWidth === 0`).
|
||||||
35
Blackboard/Todo/bug_images_cache_webapp.md
Normal file
35
Blackboard/Todo/bug_images_cache_webapp.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Bug : Images non affichées dans la webapp (fallback SVG systématique)
|
||||||
|
|
||||||
|
## Description du bug
|
||||||
|
|
||||||
|
Dans l'application web Control Point, les images de couverture d'album ne s'affichent plus correctement. Au lieu d'afficher les images, seuls les petits logos SVG de fallback (icône Music) sont visibles, alors que :
|
||||||
|
- Les URLs des images sont correctes
|
||||||
|
- Les images sont bien présentes dans le cache
|
||||||
|
- Les images sont accessibles via leur URL directe
|
||||||
|
|
||||||
|
Ce bug est apparu après une correction précédente visant à éliminer les images grises.
|
||||||
|
|
||||||
|
## Symptômes
|
||||||
|
|
||||||
|
- Les composants affichent l'icône SVG de fallback au lieu des vraies images
|
||||||
|
- Le problème est plus fréquent qu'avant la correction précédente
|
||||||
|
- Les images en cache du navigateur ne s'affichent pas
|
||||||
|
|
||||||
|
## Crates/Modules concernées
|
||||||
|
|
||||||
|
- **pmoapp/webapp** (application Vue.js)
|
||||||
|
|
||||||
|
## Composants à examiner
|
||||||
|
|
||||||
|
- `src/components/pmocontrol/CurrentTrack.vue`
|
||||||
|
- `src/components/pmocontrol/QueueItem.vue`
|
||||||
|
- `src/components/pmocontrol/MediaItem.vue`
|
||||||
|
- `src/components/pmocontrol/RendererCard.vue`
|
||||||
|
|
||||||
|
## Cause suspectée
|
||||||
|
|
||||||
|
Le pattern d'affichage d'image avec `v-show="imageLoaded"` ne gère pas correctement le cas où l'image est déjà en cache du navigateur. Dans ce cas, l'événement `@load` peut se déclencher de manière synchrone avant que Vue n'ait attaché l'écouteur, laissant `imageLoaded` à `false`.
|
||||||
|
|
||||||
|
## Solution attendue
|
||||||
|
|
||||||
|
Ajouter une vérification de l'état `complete` de l'image après le montage du composant et après chaque changement d'URL, pour détecter les images déjà chargées depuis le cache.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.10"
|
version = "0.3.11"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, toRef, ref, watch } from "vue";
|
import { computed, toRef, ref, watch, onMounted, nextTick } from "vue";
|
||||||
import { useRenderer } from "@/composables/useRenderers";
|
import { useRenderer } from "@/composables/useRenderers";
|
||||||
import { useUIStore } from "@/stores/ui";
|
import { useUIStore } from "@/stores/ui";
|
||||||
import { api } from "@/services/pmocontrol/api";
|
import { api } from "@/services/pmocontrol/api";
|
||||||
@@ -25,6 +25,9 @@ const showMetadata = ref(false);
|
|||||||
const imageLoaded = ref(false);
|
const imageLoaded = ref(false);
|
||||||
const imageError = ref(false);
|
const imageError = ref(false);
|
||||||
|
|
||||||
|
// Reference to the image element
|
||||||
|
const coverImageRef = ref<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
function openCoverOverlay() {
|
function openCoverOverlay() {
|
||||||
if (hasCover.value) {
|
if (hasCover.value) {
|
||||||
showCoverOverlay.value = true;
|
showCoverOverlay.value = true;
|
||||||
@@ -96,15 +99,35 @@ 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
|
// Reset image state when album_art_uri changes
|
||||||
watch(
|
watch(
|
||||||
() => metadata.value?.album_art_uri,
|
() => metadata.value?.album_art_uri,
|
||||||
() => {
|
(newUri) => {
|
||||||
imageLoaded.value = false;
|
imageLoaded.value = false;
|
||||||
imageError.value = false;
|
imageError.value = false;
|
||||||
|
if (newUri) {
|
||||||
|
checkImageComplete();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkImageComplete();
|
||||||
|
});
|
||||||
|
|
||||||
function handleImageLoad() {
|
function handleImageLoad() {
|
||||||
imageLoaded.value = true;
|
imageLoaded.value = true;
|
||||||
imageError.value = false;
|
imageError.value = false;
|
||||||
@@ -358,6 +381,7 @@ const swipeOpacity = computed(() => {
|
|||||||
@click="openCoverOverlay"
|
@click="openCoverOverlay"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
ref="coverImageRef"
|
||||||
v-if="metadata?.album_art_uri && !imageError"
|
v-if="metadata?.album_art_uri && !imageError"
|
||||||
v-show="imageLoaded"
|
v-show="imageLoaded"
|
||||||
:src="metadata.album_art_uri"
|
:src="metadata.album_art_uri"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch, onMounted, nextTick } 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";
|
||||||
@@ -19,15 +19,38 @@ const emit = defineEmits<{
|
|||||||
const imageLoaded = ref(false);
|
const imageLoaded = ref(false);
|
||||||
const imageError = 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
|
// Reset image state when album_art_uri changes
|
||||||
watch(
|
watch(
|
||||||
() => props.entry.album_art_uri,
|
() => props.entry.album_art_uri,
|
||||||
() => {
|
(newUri) => {
|
||||||
imageLoaded.value = false;
|
imageLoaded.value = false;
|
||||||
imageError.value = false;
|
imageError.value = false;
|
||||||
|
if (newUri) {
|
||||||
|
checkImageComplete();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkImageComplete();
|
||||||
|
});
|
||||||
|
|
||||||
function handleImageLoad() {
|
function handleImageLoad() {
|
||||||
imageLoaded.value = true;
|
imageLoaded.value = true;
|
||||||
imageError.value = false;
|
imageError.value = false;
|
||||||
@@ -51,6 +74,7 @@ function handleAddToQueue(rendererId: string) {
|
|||||||
<!-- Cover miniature -->
|
<!-- Cover miniature -->
|
||||||
<div class="media-cover">
|
<div class="media-cover">
|
||||||
<img
|
<img
|
||||||
|
ref="coverImageRef"
|
||||||
v-if="entry.album_art_uri && !imageError"
|
v-if="entry.album_art_uri && !imageError"
|
||||||
v-show="imageLoaded"
|
v-show="imageLoaded"
|
||||||
:src="entry.album_art_uri"
|
:src="entry.album_art_uri"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch, onMounted, nextTick } 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";
|
||||||
|
|
||||||
@@ -16,15 +16,38 @@ const emit = defineEmits<{
|
|||||||
const imageLoaded = ref(false);
|
const imageLoaded = ref(false);
|
||||||
const imageError = 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
|
// Reset image state when album_art_uri changes
|
||||||
watch(
|
watch(
|
||||||
() => props.item.album_art_uri,
|
() => props.item.album_art_uri,
|
||||||
() => {
|
(newUri) => {
|
||||||
imageLoaded.value = false;
|
imageLoaded.value = false;
|
||||||
imageError.value = false;
|
imageError.value = false;
|
||||||
|
if (newUri) {
|
||||||
|
checkImageComplete();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkImageComplete();
|
||||||
|
});
|
||||||
|
|
||||||
function handleImageLoad() {
|
function handleImageLoad() {
|
||||||
imageLoaded.value = true;
|
imageLoaded.value = true;
|
||||||
imageError.value = false;
|
imageError.value = false;
|
||||||
@@ -56,6 +79,7 @@ function handleClick(item: QueueItem) {
|
|||||||
<!-- Cover miniature -->
|
<!-- Cover miniature -->
|
||||||
<div class="item-cover">
|
<div class="item-cover">
|
||||||
<img
|
<img
|
||||||
|
ref="coverImageRef"
|
||||||
v-if="item.album_art_uri && !imageError"
|
v-if="item.album_art_uri && !imageError"
|
||||||
v-show="imageLoaded"
|
v-show="imageLoaded"
|
||||||
:src="item.album_art_uri"
|
:src="item.album_art_uri"
|
||||||
|
|||||||
@@ -1,331 +1,393 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch, onMounted, nextTick } from "vue";
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from "vue-router";
|
||||||
import type {
|
import type {
|
||||||
RendererCapabilitiesSummary,
|
RendererCapabilitiesSummary,
|
||||||
RendererSummary,
|
RendererSummary,
|
||||||
RendererState,
|
RendererState,
|
||||||
} 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";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
renderer: RendererSummary
|
renderer: RendererSummary;
|
||||||
state: RendererState | null
|
state: RendererState | null;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const router = useRouter()
|
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
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const protocolLabel = computed(() => {
|
const protocolLabel = computed(() => {
|
||||||
switch (props.renderer.protocol) {
|
switch (props.renderer.protocol) {
|
||||||
case 'upnp':
|
case "upnp":
|
||||||
return 'UPnP AV'
|
return "UPnP AV";
|
||||||
case 'openhome':
|
case "openhome":
|
||||||
return 'OpenHome'
|
return "OpenHome";
|
||||||
case 'hybrid':
|
case "hybrid":
|
||||||
return 'Hybrid (UPnP + OpenHome)'
|
return "Hybrid (UPnP + OpenHome)";
|
||||||
default:
|
default:
|
||||||
return 'Inconnu'
|
return "Inconnu";
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const protocolClass = computed(() => {
|
const protocolClass = computed(() => {
|
||||||
switch (props.renderer.protocol) {
|
switch (props.renderer.protocol) {
|
||||||
case 'upnp':
|
case "upnp":
|
||||||
return 'protocol-upnp'
|
return "protocol-upnp";
|
||||||
case 'openhome':
|
case "openhome":
|
||||||
return 'protocol-openhome'
|
return "protocol-openhome";
|
||||||
case 'hybrid':
|
case "hybrid":
|
||||||
return 'protocol-hybrid'
|
return "protocol-hybrid";
|
||||||
default:
|
default:
|
||||||
return 'protocol-unknown'
|
return "protocol-unknown";
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const capabilityBadges = computed(() => {
|
const capabilityBadges = computed(() => {
|
||||||
const caps = props.renderer.capabilities
|
const caps = props.renderer.capabilities;
|
||||||
if (!caps) return []
|
if (!caps) return [];
|
||||||
const mapping: Array<{ key: keyof RendererCapabilitiesSummary; label: string }> = [
|
const mapping: Array<{
|
||||||
{ key: 'has_avtransport', label: 'AVTransport' },
|
key: keyof RendererCapabilitiesSummary;
|
||||||
{ key: 'has_oh_playlist', label: 'OpenHome' },
|
label: string;
|
||||||
{ key: 'has_linkplay_http', label: 'Hybrid' },
|
}> = [
|
||||||
{ key: 'has_oh_volume', label: 'Vol' },
|
{ key: "has_avtransport", label: "AVTransport" },
|
||||||
{ key: 'has_oh_time', label: 'Time' },
|
{ key: "has_oh_playlist", label: "OpenHome" },
|
||||||
{ key: 'has_oh_info', label: 'Info' },
|
{ key: "has_linkplay_http", label: "Hybrid" },
|
||||||
]
|
{ key: "has_oh_volume", label: "Vol" },
|
||||||
return mapping.filter(({ key }) => caps[key]).map(({ key, label }) => ({ key, label }))
|
{ key: "has_oh_time", label: "Time" },
|
||||||
})
|
{ key: "has_oh_info", label: "Info" },
|
||||||
|
];
|
||||||
|
return mapping
|
||||||
|
.filter(({ key }) => caps[key])
|
||||||
|
.map(({ key, label }) => ({ key, label }));
|
||||||
|
});
|
||||||
|
|
||||||
const hasCover = computed(() => !!metadata.value?.album_art_uri)
|
const hasCover = computed(
|
||||||
|
() => !!metadata.value?.album_art_uri && !imageError.value,
|
||||||
|
);
|
||||||
|
|
||||||
function goToRenderer() {
|
function goToRenderer() {
|
||||||
router.push(`/renderer/${props.renderer.id}`)
|
router.push(`/renderer/${props.renderer.id}`);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="['renderer-card', { offline: !renderer.online }]">
|
<div :class="['renderer-card', { offline: !renderer.online }]">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<h3 class="renderer-name">{{ renderer.friendly_name }}</h3>
|
<h3 class="renderer-name">{{ renderer.friendly_name }}</h3>
|
||||||
<p class="renderer-model">{{ renderer.model_name }}</p>
|
<p class="renderer-model">{{ renderer.model_name }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="badges">
|
<div class="badges">
|
||||||
<span :class="['protocol-badge', protocolClass]">
|
<span :class="['protocol-badge', protocolClass]">
|
||||||
{{ protocolLabel }}
|
{{ protocolLabel }}
|
||||||
</span>
|
</span>
|
||||||
<StatusBadge v-if="state" :status="state.transport_state" />
|
<StatusBadge v-if="state" :status="state.transport_state" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cover Art -->
|
<!-- Cover Art -->
|
||||||
<div v-if="capabilityBadges.length" class="capabilities">
|
<div v-if="capabilityBadges.length" class="capabilities">
|
||||||
<span v-for="badge in capabilityBadges" :key="badge.key" class="capability-badge">
|
<span
|
||||||
{{ badge.label }}
|
v-for="badge in capabilityBadges"
|
||||||
</span>
|
:key="badge.key"
|
||||||
</div>
|
class="capability-badge"
|
||||||
|
>
|
||||||
|
{{ badge.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-cover">
|
<div class="card-cover">
|
||||||
<img
|
<img
|
||||||
v-if="hasCover"
|
ref="coverImageRef"
|
||||||
:src="metadata?.album_art_uri!"
|
v-if="hasCover"
|
||||||
:alt="metadata?.album || 'Album cover'"
|
v-show="imageLoaded"
|
||||||
class="cover-image"
|
:src="metadata?.album_art_uri!"
|
||||||
loading="lazy"
|
:alt="metadata?.album || 'Album cover'"
|
||||||
/>
|
class="cover-image"
|
||||||
<div v-else class="cover-placeholder">
|
loading="lazy"
|
||||||
<Music :size="48" />
|
@load="handleImageLoad"
|
||||||
</div>
|
@error="handleImageError"
|
||||||
</div>
|
/>
|
||||||
|
<div v-if="!hasCover || !imageLoaded" class="cover-placeholder">
|
||||||
|
<Music :size="48" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Metadata (current track) -->
|
<!-- Metadata (current track) -->
|
||||||
<div v-if="metadata" class="card-metadata">
|
<div v-if="metadata" class="card-metadata">
|
||||||
<p class="track-title">{{ metadata.title || 'Sans titre' }}</p>
|
<p class="track-title">{{ metadata.title || "Sans titre" }}</p>
|
||||||
<p class="track-artist">{{ metadata.artist || 'Artiste inconnu' }}</p>
|
<p class="track-artist">
|
||||||
</div>
|
{{ metadata.artist || "Artiste inconnu" }}
|
||||||
<div v-else class="card-metadata empty">
|
</p>
|
||||||
<p class="track-title">Aucun média</p>
|
</div>
|
||||||
</div>
|
<div v-else class="card-metadata empty">
|
||||||
|
<p class="track-title">Aucun média</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Volume -->
|
<!-- Volume -->
|
||||||
<div v-if="state && state.volume !== null" class="card-volume">
|
<div v-if="state && state.volume !== null" class="card-volume">
|
||||||
<VolumeX v-if="state.mute" :size="16" class="volume-icon muted" />
|
<VolumeX v-if="state.mute" :size="16" class="volume-icon muted" />
|
||||||
<Volume2 v-else :size="16" class="volume-icon" />
|
<Volume2 v-else :size="16" class="volume-icon" />
|
||||||
<div class="volume-bar">
|
<div class="volume-bar">
|
||||||
<div
|
<div
|
||||||
class="volume-bar-fill"
|
class="volume-bar-fill"
|
||||||
:style="{ width: `${state.mute ? 0 : state.volume}%` }"
|
:style="{ width: `${state.mute ? 0 : state.volume}%` }"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="volume-value">{{ state.volume }}</span>
|
<span class="volume-value">{{ state.volume }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Control Button -->
|
<!-- Control Button -->
|
||||||
<button class="btn btn-primary card-control-btn" @click="goToRenderer">
|
<button class="btn btn-primary card-control-btn" @click="goToRenderer">
|
||||||
Contrôler
|
Contrôler
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.renderer-card {
|
.renderer-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
padding: var(--spacing-lg);
|
padding: var(--spacing-lg);
|
||||||
background-color: var(--color-bg-secondary);
|
background-color: var(--color-bg-secondary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
transition: all var(--transition-normal);
|
transition: all var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.renderer-card:hover {
|
.renderer-card:hover {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.renderer-card.offline {
|
.renderer-card.offline {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
filter: grayscale(0.5);
|
filter: grayscale(0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.renderer-name {
|
.renderer-name {
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
margin: 0 0 var(--spacing-xs);
|
margin: 0 0 var(--spacing-xs);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.renderer-model {
|
.renderer-model {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badges {
|
.badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-xs);
|
gap: var(--spacing-xs);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.capabilities {
|
.capabilities {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--spacing-xs);
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-badge {
|
.capability-badge {
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
background-color: var(--color-bg-tertiary);
|
background-color: var(--color-bg-tertiary);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.protocol-badge {
|
.protocol-badge {
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.protocol-upnp {
|
.protocol-upnp {
|
||||||
background-color: rgba(59, 130, 246, 0.1);
|
background-color: rgba(59, 130, 246, 0.1);
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
border: 1px solid #3b82f6;
|
border: 1px solid #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.protocol-openhome {
|
.protocol-openhome {
|
||||||
background-color: rgba(139, 92, 246, 0.1);
|
background-color: rgba(139, 92, 246, 0.1);
|
||||||
color: #8b5cf6;
|
color: #8b5cf6;
|
||||||
border: 1px solid #8b5cf6;
|
border: 1px solid #8b5cf6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.protocol-hybrid {
|
.protocol-hybrid {
|
||||||
background-color: rgba(16, 185, 129, 0.1);
|
background-color: rgba(16, 185, 129, 0.1);
|
||||||
color: #10b981;
|
color: #10b981;
|
||||||
border: 1px solid #10b981;
|
border: 1px solid #10b981;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cover */
|
/* Cover */
|
||||||
.card-cover {
|
.card-cover {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: var(--color-bg-tertiary);
|
background-color: var(--color-bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover-image {
|
.cover-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover-placeholder {
|
.cover-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--color-text-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Metadata */
|
/* Metadata */
|
||||||
.card-metadata {
|
.card-metadata {
|
||||||
min-height: 3rem;
|
min-height: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-title {
|
.track-title {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
margin: 0 0 var(--spacing-xs);
|
margin: 0 0 var(--spacing-xs);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-artist {
|
.track-artist {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-metadata.empty .track-title {
|
.card-metadata.empty .track-title {
|
||||||
color: var(--color-text-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Volume */
|
/* Volume */
|
||||||
.card-volume {
|
.card-volume {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-icon {
|
.volume-icon {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-icon.muted {
|
.volume-icon.muted {
|
||||||
color: var(--status-offline);
|
color: var(--status-offline);
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-bar {
|
.volume-bar {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background-color: var(--color-bg-tertiary);
|
background-color: var(--color-bg-tertiary);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-bar-fill {
|
.volume-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
transition: width var(--transition-fast);
|
transition: width var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-value {
|
.volume-value {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
min-width: 2rem;
|
min-width: 2rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Control Button */
|
/* Control Button */
|
||||||
.card-control-btn {
|
.card-control-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive grid handled by parent (DashboardView) */
|
/* Responsive grid handled by parent (DashboardView) */
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.3.10
|
0.3.11
|
||||||
|
|||||||
Reference in New Issue
Block a user