Bump version to 0.3.10

Update version number from 0.3.9 to 0.3.10 in Cargo.toml and version.txt files.

Also, enhance image loading handling in multiple Vue components (ContainerItem, CurrentTrack, MediaItem, QueueItem, ServerDrawer) by:
- Adding ref and watch for tracking image loading states
- Implementing proper load and error handlers
- Using v-show to control image visibility based on loading state
- Adding image state tracking for each item in ServerDrawer
- Improving placeholder display logic
This commit is contained in:
2026-01-17 21:06:03 +01:00
parent f290878c5f
commit c9dda75628
8 changed files with 355 additions and 248 deletions

2
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "PMOMusic"
version = "0.3.9"
version = "0.3.10"
dependencies = [
"axum 0.8.7",
"console-subscriber",

View File

@@ -1,6 +1,6 @@
[package]
name = "PMOMusic"
version = "0.3.9"
version = "0.3.10"
edition = "2024"
[dependencies]

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref, watch } from "vue";
import type { ContainerEntry } from "@/services/pmocontrol/types";
import { Folder, Music } from "lucide-vue-next";
import ActionMenu from "./ActionMenu.vue";
@@ -16,6 +16,19 @@ 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;
},
);
const iconComponent = computed(() => {
const cls = props.entry.class.toLowerCase();
if (cls.includes("playlist")) return Music;
@@ -49,13 +62,13 @@ function handleAddToQueue(rendererId: string) {
emit("addToQueue", props.entry.id, rendererId);
}
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement;
img.style.display = "none";
const placeholder = img.nextElementSibling;
if (placeholder && placeholder instanceof HTMLElement) {
placeholder.style.display = "flex";
}
function handleImageLoad() {
imageLoaded.value = true;
imageError.value = false;
}
function handleImageError() {
imageError.value = true;
}
</script>
@@ -66,18 +79,18 @@ function handleImageError(event: Event) {
<!-- Cover avec icône de type en overlay -->
<div class="container-cover">
<img
v-if="entry.album_art_uri"
v-if="entry.album_art_uri && !imageError"
v-show="imageLoaded"
:src="entry.album_art_uri"
:alt="entry.title"
class="cover-image"
loading="lazy"
@load="handleImageLoad"
@error="handleImageError"
/>
<div
v-if="!entry.album_art_uri || imageError || !imageLoaded"
class="cover-placeholder"
:style="{
display: entry.album_art_uri ? 'none' : 'flex',
}"
>
<component :is="iconComponent" :size="28" />
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, toRef, ref } from "vue";
import { computed, toRef, ref, watch } from "vue";
import { useRenderer } from "@/composables/useRenderers";
import { useUIStore } from "@/stores/ui";
import { api } from "@/services/pmocontrol/api";
@@ -21,6 +21,10 @@ 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);
function openCoverOverlay() {
if (hasCover.value) {
showCoverOverlay.value = true;
@@ -88,11 +92,26 @@ function formatTime(ms: number | null | undefined): string {
const currentTime = computed(() => formatTime(state.value?.position_ms));
const totalTime = computed(() => formatTime(state.value?.duration_ms));
const hasCover = computed(() => !!metadata.value?.album_art_uri);
const hasCover = computed(
() => !!metadata.value?.album_art_uri && !imageError.value,
);
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement;
img.style.display = "none";
// Reset image state when album_art_uri changes
watch(
() => metadata.value?.album_art_uri,
() => {
imageLoaded.value = false;
imageError.value = false;
},
);
function handleImageLoad() {
imageLoaded.value = true;
imageError.value = false;
}
function handleImageError() {
imageError.value = true;
}
// Calculer le pourcentage à partir d'une position X dans la barre
@@ -339,14 +358,19 @@ const swipeOpacity = computed(() => {
@click="openCoverOverlay"
>
<img
v-if="hasCover"
:src="metadata?.album_art_uri!"
v-if="metadata?.album_art_uri && !imageError"
v-show="imageLoaded"
:src="metadata.album_art_uri"
:alt="metadata?.album || 'Album cover'"
class="cover-image"
loading="lazy"
@load="handleImageLoad"
@error="handleImageError"
/>
<div v-else class="cover-placeholder">
<div
v-if="!metadata?.album_art_uri || imageError || !imageLoaded"
class="cover-placeholder"
>
<Music :size="64" />
</div>
</div>

View File

@@ -1,185 +1,208 @@
<script setup lang="ts">
import type { ContainerEntry } from '@/services/pmocontrol/types'
import { Music } from 'lucide-vue-next'
import ActionMenu from './ActionMenu.vue'
import { ref, watch } from "vue";
import type { ContainerEntry } from "@/services/pmocontrol/types";
import { Music } from "lucide-vue-next";
import ActionMenu from "./ActionMenu.vue";
const props = defineProps<{
entry: ContainerEntry
serverId: string
showActions?: boolean
}>()
entry: ContainerEntry;
serverId: string;
showActions?: boolean;
}>();
const emit = defineEmits<{
playNow: [itemId: string, rendererId: string]
addToQueue: [itemId: string, rendererId: string]
}>()
playNow: [itemId: string, rendererId: string];
addToQueue: [itemId: string, rendererId: string];
}>();
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
img.style.display = 'none'
const placeholder = img.nextElementSibling
if (placeholder && placeholder instanceof HTMLElement) {
placeholder.style.display = 'flex'
}
// 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;
},
);
function handleImageLoad() {
imageLoaded.value = true;
imageError.value = false;
}
function handleImageError() {
imageError.value = true;
}
function handlePlayNow(rendererId: string) {
emit('playNow', props.entry.id, rendererId)
emit("playNow", props.entry.id, rendererId);
}
function handleAddToQueue(rendererId: string) {
emit('addToQueue', props.entry.id, rendererId)
emit("addToQueue", props.entry.id, rendererId);
}
</script>
<template>
<div class="media-item">
<!-- Cover miniature -->
<div class="media-cover">
<img
v-if="entry.album_art_uri"
:src="entry.album_art_uri"
:alt="entry.album || entry.title"
class="cover-image"
loading="lazy"
@error="handleImageError"
/>
<div class="cover-placeholder" :style="{ display: entry.album_art_uri ? 'none' : 'flex' }">
<Music :size="20" />
</div>
</div>
<div class="media-item">
<!-- Cover miniature -->
<div class="media-cover">
<img
v-if="entry.album_art_uri && !imageError"
v-show="imageLoaded"
:src="entry.album_art_uri"
:alt="entry.album || entry.title"
class="cover-image"
loading="lazy"
@load="handleImageLoad"
@error="handleImageError"
/>
<div
v-if="!entry.album_art_uri || imageError || !imageLoaded"
class="cover-placeholder"
>
<Music :size="20" />
</div>
</div>
<!-- Metadata -->
<div class="media-metadata">
<div class="media-title">{{ entry.title }}</div>
<div class="media-details">
<span v-if="entry.artist" class="media-artist">{{ entry.artist }}</span>
<span v-if="entry.album" class="media-album">{{ entry.album }}</span>
</div>
</div>
<!-- Metadata -->
<div class="media-metadata">
<div class="media-title">{{ entry.title }}</div>
<div class="media-details">
<span v-if="entry.artist" class="media-artist">{{
entry.artist
}}</span>
<span v-if="entry.album" class="media-album">{{
entry.album
}}</span>
</div>
</div>
<!-- Actions menu -->
<div class="media-actions">
<ActionMenu
type="item"
:entry-id="entry.id"
:server-id="serverId"
@play-now="handlePlayNow"
@add-to-queue="handleAddToQueue"
/>
<!-- Actions menu -->
<div class="media-actions">
<ActionMenu
type="item"
:entry-id="entry.id"
:server-id="serverId"
@play-now="handlePlayNow"
@add-to-queue="handleAddToQueue"
/>
</div>
</div>
</div>
</template>
<style scoped>
.media-item {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm);
border-radius: var(--radius-md);
transition: background-color var(--transition-fast);
border: 1px solid transparent;
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm);
border-radius: var(--radius-md);
transition: background-color var(--transition-fast);
border: 1px solid transparent;
}
.media-item:hover {
background-color: var(--color-bg-secondary);
border-color: var(--color-border);
background-color: var(--color-bg-secondary);
border-color: var(--color-border);
}
/* Cover */
.media-cover {
position: relative;
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: var(--radius-sm);
overflow: hidden;
background-color: var(--color-bg-tertiary);
position: relative;
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: var(--radius-sm);
overflow: hidden;
background-color: var(--color-bg-tertiary);
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-tertiary);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-tertiary);
}
/* Metadata */
.media-metadata {
flex: 1;
min-width: 0;
flex: 1;
min-width: 0;
}
.media-title {
font-size: var(--text-base);
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: var(--spacing-xs);
font-size: var(--text-base);
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: var(--spacing-xs);
}
.media-details {
display: flex;
gap: var(--spacing-sm);
font-size: var(--text-sm);
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
gap: var(--spacing-sm);
font-size: var(--text-sm);
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-artist {
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.media-album {
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.media-album::before {
content: '•';
margin-right: var(--spacing-sm);
content: "•";
margin-right: var(--spacing-sm);
}
/* Actions */
.media-actions {
flex-shrink: 0;
flex-shrink: 0;
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-icon:hover {
background-color: var(--color-bg-tertiary);
color: var(--color-text);
background-color: var(--color-bg-tertiary);
color: var(--color-text);
}
</style>

View File

@@ -1,139 +1,170 @@
<script setup lang="ts">
import { Music, Play } from 'lucide-vue-next'
import type { QueueItem } from '@/services/pmocontrol/types'
import { ref, watch } from "vue";
import { Music, Play } from "lucide-vue-next";
import type { QueueItem } from "@/services/pmocontrol/types";
defineProps<{
item: QueueItem
isCurrent: boolean
}>()
const props = defineProps<{
item: QueueItem;
isCurrent: boolean;
}>();
const emit = defineEmits<{
click: [item: QueueItem]
}>()
click: [item: QueueItem];
}>();
// Track image loading state
const imageLoaded = ref(false);
const imageError = ref(false);
// Reset image state when album_art_uri changes
watch(
() => props.item.album_art_uri,
() => {
imageLoaded.value = false;
imageError.value = false;
},
);
function handleImageLoad() {
imageLoaded.value = true;
imageError.value = false;
}
function handleImageError() {
imageError.value = true;
}
function handleClick(item: QueueItem) {
console.log('[QueueItem] Click detected on item:', item.index, item.title)
emit('click', item)
console.log("[QueueItem] Click detected on item:", item.index, item.title);
emit("click", item);
}
</script>
<template>
<div :class="['queue-item', { current: isCurrent }]" @click="handleClick(item)">
<!-- Indicateur piste en cours -->
<div class="current-indicator" v-if="isCurrent">
<Play :size="16" fill="currentColor" />
</div>
<div
:class="['queue-item', { current: isCurrent }]"
@click="handleClick(item)"
>
<!-- Indicateur piste en cours -->
<div class="current-indicator" v-if="isCurrent">
<Play :size="16" fill="currentColor" />
</div>
<!-- Index (1-based pour l'affichage) -->
<span class="item-index">{{ item.index + 1 }}</span>
<!-- Index (1-based pour l'affichage) -->
<span class="item-index">{{ item.index + 1 }}</span>
<!-- Cover miniature -->
<div class="item-cover">
<img
v-if="item.album_art_uri"
:src="item.album_art_uri"
:alt="item.album || 'Album cover'"
class="cover-image"
loading="lazy"
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<Music v-else :size="20" />
</div>
<!-- Cover miniature -->
<div class="item-cover">
<img
v-if="item.album_art_uri && !imageError"
v-show="imageLoaded"
:src="item.album_art_uri"
:alt="item.album || 'Album cover'"
class="cover-image"
loading="lazy"
@load="handleImageLoad"
@error="handleImageError"
/>
<Music
v-if="!item.album_art_uri || imageError || !imageLoaded"
:size="20"
/>
</div>
<!-- Métadonnées -->
<div class="item-metadata">
<div class="item-title">{{ item.title || 'Sans titre' }}</div>
<div class="item-artist">
{{ item.artist || 'Artiste inconnu' }}
<span v-if="item.album"> {{ item.album }}</span>
</div>
<!-- Métadonnées -->
<div class="item-metadata">
<div class="item-title">{{ item.title || "Sans titre" }}</div>
<div class="item-artist">
{{ item.artist || "Artiste inconnu" }}
<span v-if="item.album"> {{ item.album }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.queue-item {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm);
border-radius: var(--radius-md);
transition: background-color var(--transition-fast);
position: relative;
cursor: pointer;
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm);
border-radius: var(--radius-md);
transition: background-color var(--transition-fast);
position: relative;
cursor: pointer;
}
.queue-item:hover {
background-color: var(--color-bg-secondary);
background-color: var(--color-bg-secondary);
}
.queue-item.current {
background-color: var(--status-playing-bg);
border: 1px solid var(--status-playing);
font-weight: 600;
background-color: var(--status-playing-bg);
border: 1px solid var(--status-playing);
font-weight: 600;
}
.current-indicator {
position: absolute;
left: var(--spacing-xs);
color: var(--status-playing);
display: flex;
align-items: center;
position: absolute;
left: var(--spacing-xs);
color: var(--status-playing);
display: flex;
align-items: center;
}
.item-index {
font-size: var(--text-sm);
color: var(--color-text-secondary);
min-width: 2rem;
text-align: right;
font-variant-numeric: tabular-nums;
font-size: var(--text-sm);
color: var(--color-text-secondary);
min-width: 2rem;
text-align: right;
font-variant-numeric: tabular-nums;
}
.queue-item.current .item-index {
margin-left: var(--spacing-lg);
margin-left: var(--spacing-lg);
}
.item-cover {
width: 48px;
height: 48px;
background-color: var(--color-bg-tertiary);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-tertiary);
flex-shrink: 0;
overflow: hidden;
width: 48px;
height: 48px;
background-color: var(--color-bg-tertiary);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-tertiary);
flex-shrink: 0;
overflow: hidden;
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
width: 100%;
height: 100%;
object-fit: cover;
}
.item-metadata {
flex: 1;
min-width: 0;
flex: 1;
min-width: 0;
}
.item-title {
font-size: var(--text-base);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-base);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-item.current .item-title {
color: var(--status-playing);
color: var(--status-playing);
}
.item-artist {
font-size: var(--text-sm);
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-sm);
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { ref, computed, watch, reactive } from "vue";
import { useRouter } from "vue-router";
import {
X,
@@ -53,6 +53,29 @@ const isLoading = ref(false);
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)
const openMenuId = ref<string | null>(null);
// Track image loading state per item
const imageStates = reactive<Map<string, { loaded: boolean; error: boolean }>>(
new Map(),
);
function getImageState(itemId: string) {
if (!imageStates.has(itemId)) {
imageStates.set(itemId, { loaded: false, error: false });
}
return imageStates.get(itemId)!;
}
function handleImageLoad(itemId: string) {
const state = getImageState(itemId);
state.loaded = true;
state.error = false;
}
function handleImageError(itemId: string) {
const state = getImageState(itemId);
state.error = true;
}
// Rafraîchir la liste quand le drawer s'ouvre
watch(
() => props.modelValue,
@@ -65,6 +88,7 @@ watch(
browseData.value = null;
clearPath();
closeMenu();
imageStates.clear();
}
},
);
@@ -470,33 +494,25 @@ function handleSettingsClick() {
<!-- Cover avec image ou icône -->
<div class="content-cover">
<img
v-if="item.album_art_uri"
v-if="
item.album_art_uri &&
!getImageState(item.id).error
"
v-show="getImageState(item.id).loaded"
:src="item.album_art_uri"
:alt="item.title"
class="cover-image"
loading="lazy"
@error="
(e) => {
(
e.target as HTMLImageElement
).style.display = 'none';
const placeholder = (
e.target as HTMLElement
)
.nextElementSibling as HTMLElement;
if (placeholder)
placeholder.style.display =
'flex';
}
"
@load="handleImageLoad(item.id)"
@error="handleImageError(item.id)"
/>
<div
v-if="
!item.album_art_uri ||
getImageState(item.id).error ||
!getImageState(item.id).loaded
"
class="cover-placeholder"
:style="{
display: item.album_art_uri
? 'none'
: 'flex',
}"
>
<Folder
v-if="item.is_container"

View File

@@ -1 +1 @@
0.3.9
0.3.10