Merge pull request 'Bump version to 0.3.10' (#64) from push-koulrlvkzszm into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 28m19s

Reviewed-on: #64
This commit was merged in pull request #64.
This commit is contained in:
2026-01-17 21:07:50 +01:00
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