From 2b3fae7778552200b20b6288e6ee2dde73df7c80 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 7 Jun 2026 14:03:06 +0200 Subject: [PATCH 1/6] feat: handle SSE cache invalidation and nightly rust toolchain config Adds a Vue watch in `ServerDrawer.vue` to trigger a manual container directory refresh when SSE cache invalidation clears `browseData`. Includes guards to prevent redundant requests during active loads and properly manages the `isLoading` state. Additionally, introduces `rust-toolchain.toml` to enforce the nightly Rust compiler across all environments, ensuring consistent builds and access to recent language improvements. --- .../webapp/src/components/unified/ServerDrawer.vue | 14 ++++++++++++++ pmoaudio/src/nodes/flac_file_sink.rs | 2 +- rust-toolchain.toml | 2 ++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 rust-toolchain.toml diff --git a/pmoapp/webapp/src/components/unified/ServerDrawer.vue b/pmoapp/webapp/src/components/unified/ServerDrawer.vue index 0f2783e0..808867e5 100644 --- a/pmoapp/webapp/src/components/unified/ServerDrawer.vue +++ b/pmoapp/webapp/src/components/unified/ServerDrawer.vue @@ -113,6 +113,20 @@ watch(drawerContentRef, (el) => { if (el) setupObserver(); }); +// Recharger quand le cache est invalidé par un événement SSE (containers_updated / global_updated) +watch(browseData, async (data, oldData) => { + if (!data && oldData && currentServer.value && currentContainerId.value && !isLoading.value) { + isLoading.value = true; + try { + await browseContainer(currentServer.value.id, currentContainerId.value, false); + } catch (error) { + console.error("[ServerDrawer] Erreur reload après invalidation cache:", error); + } finally { + isLoading.value = false; + } + } +}); + // État du menu dropdown (pour chaque item, on stocke si son menu est ouvert) const openMenuId = ref(null); diff --git a/pmoaudio/src/nodes/flac_file_sink.rs b/pmoaudio/src/nodes/flac_file_sink.rs index 45e612de..32439fad 100755 --- a/pmoaudio/src/nodes/flac_file_sink.rs +++ b/pmoaudio/src/nodes/flac_file_sink.rs @@ -2,7 +2,7 @@ use crate::{ nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE}, pipeline::{Node, NodeLogic}, type_constraints::TypeRequirement, - AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker, StreamType, + AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker, }; use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat}; use std::{ diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..5d56faf9 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" -- 2.49.1 From 037435af20307d37dac490cadf9350c71feed9d0 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 7 Jun 2026 14:25:43 +0200 Subject: [PATCH 2/6] style: optimize drawer layout for mobile portrait viewports Update mobile portrait media queries in RendererDrawer and ServerDrawer to span full viewport width, reduce background opacity to 6%, and remove side box shadows. This flattens the visual style and improves layout for narrow screens. --- .../webapp/src/components/unified/RendererDrawer.vue | 10 ++-------- pmoapp/webapp/src/components/unified/ServerDrawer.vue | 10 ++-------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/pmoapp/webapp/src/components/unified/RendererDrawer.vue b/pmoapp/webapp/src/components/unified/RendererDrawer.vue index 1e81e9b1..052d681e 100644 --- a/pmoapp/webapp/src/components/unified/RendererDrawer.vue +++ b/pmoapp/webapp/src/components/unified/RendererDrawer.vue @@ -860,14 +860,8 @@ async function handleTransferQueue(event: Event, targetRendererId: string) { /* Mobile responsive - portrait */ @media (max-width: 768px) and (orientation: portrait) { .renderer-drawer { - width: 100vw; /* Mobile portrait: 100% de l'écran */ - background: rgba( - 255, - 255, - 255, - 0.06 - ); /* Encore plus transparent sur mobile */ - box-shadow: none; /* Pas d'ombre sur les côtés */ + width: 100vw; + box-shadow: none; } .drawer-header { diff --git a/pmoapp/webapp/src/components/unified/ServerDrawer.vue b/pmoapp/webapp/src/components/unified/ServerDrawer.vue index 808867e5..035d41e6 100644 --- a/pmoapp/webapp/src/components/unified/ServerDrawer.vue +++ b/pmoapp/webapp/src/components/unified/ServerDrawer.vue @@ -1439,14 +1439,8 @@ function handleSettingsClick() { /* Mobile responsive - portrait */ @media (max-width: 768px) and (orientation: portrait) { .server-drawer { - width: 100vw; /* Mobile portrait: 100% de l'écran */ - background: rgba( - 255, - 255, - 255, - 0.06 - ); /* Encore plus transparent sur mobile */ - box-shadow: none; /* Pas d'ombre sur les côtés */ + width: 100vw; + box-shadow: none; } .drawer-header { -- 2.49.1 From 8bc0670de4ab891d7d22046fc1d379354c6d082f Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 7 Jun 2026 14:40:03 +0200 Subject: [PATCH 3/6] fix: correct queue drawer positioning and safe-area layout Replace malformed max-height values with precise calc() expressions and update open/closed transform states. Apply flexbox improvements to ensure reliable mobile drawer layout and proper safe-area padding above the BottomTabBar. --- .../components/unified/RendererTabContent.vue | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/pmoapp/webapp/src/components/unified/RendererTabContent.vue b/pmoapp/webapp/src/components/unified/RendererTabContent.vue index 9dd5c932..2da5d779 100644 --- a/pmoapp/webapp/src/components/unified/RendererTabContent.vue +++ b/pmoapp/webapp/src/components/unified/RendererTabContent.vue @@ -231,7 +231,8 @@ async function handleQueueItemClick(item: QueueItem) { /* Queue drawer visible sur mobile (géré par v-if maintenant) */ .queue-drawer { position: fixed; - bottom: 0; + /* Ancré juste au-dessus de la BottomTabBar (64px) et de la safe-area système */ + bottom: calc(64px + env(safe-area-inset-bottom, 0px)); left: 0; right: 0; background: rgba(22, 22, 32, 0.96); @@ -239,12 +240,11 @@ async function handleQueueItemClick(item: QueueItem) { -webkit-backdrop-filter: blur(8px); border-top: 1px solid rgba(255, 255, 255, 0.12); box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.4); - /* Fermé: caché sauf le toggle (56px) qui dépasse au-dessus de la BottomTabBar. - La BottomTabBar fait 64px + env(safe-area-inset-bottom) de padding. */ - transform: translateY(calc(100% - 56px - 64px - env(safe-area-inset-bottom, 0px))); + /* Fermé: seul le toggle (56px) visible */ + transform: translateY(calc(100% - 56px)); transition: transform 0.3s ease; z-index: 95; /* En dessous de la BottomTabBar (z-index: 100) */ - max-height: 70vh; + max-height: calc(100vh - 64px - env(safe-area-inset-bottom, 0px)); display: flex; flex-direction: column; /* Fermé: ne bloque pas les clics en dehors du toggle */ @@ -252,9 +252,9 @@ async function handleQueueItemClick(item: QueueItem) { } .queue-drawer.open { - /* Ouvert: remonte juste au-dessus de la BottomTabBar (64px + safe area) */ - transform: translateY(calc(64px + env(safe-area-inset-bottom, 0px))); - pointer-events: auto; /* Ouvert: capture les clics */ + /* Ouvert: le drawer occupe toute la hauteur disponible au-dessus de la tab bar */ + transform: translateY(0); + pointer-events: auto; } @media (prefers-color-scheme: light) { @@ -267,6 +267,7 @@ async function handleQueueItemClick(item: QueueItem) { .queue-drawer-toggle { width: 100%; height: 56px; + flex-shrink: 0; display: flex; align-items: center; justify-content: center; @@ -282,11 +283,9 @@ async function handleQueueItemClick(item: QueueItem) { } .queue-drawer-content { - max-height: calc(70vh - 56px); + flex: 1; overflow-y: auto; padding: var(--spacing-md); - /* Ensure the last item clears the safe-area / system nav bar */ - padding-bottom: max(var(--spacing-md), env(safe-area-inset-bottom, 0px)); } .queue-drawer-backdrop { @@ -295,12 +294,12 @@ async function handleQueueItemClick(item: QueueItem) { top: 0; left: 0; right: 0; - bottom: 64px; /* S'arrête au-dessus de la BottomTabBar */ + bottom: calc(64px + env(safe-area-inset-bottom, 0px)); background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); - z-index: 94; /* Entre la BottomTabBar et le drawer */ - pointer-events: auto; /* Capture les clics pour fermer le drawer */ + z-index: 94; + pointer-events: auto; } .controls-column { -- 2.49.1 From ba40d07f73e83bc273d949e72972974cef8cc371 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 7 Jun 2026 14:40:22 +0200 Subject: [PATCH 4/6] fix: prevent direct playback of single-item playlists Update `isNavigable` to explicitly validate `item.is_container` and disable navigation for playlists with only one item. This forces users to enter the playlist container, preserving metadata update benefits. --- pmoapp/webapp/src/components/unified/ServerDrawer.vue | 8 ++++++-- pmocontrol/src/media_server.rs | 3 +++ pmocontrol/src/pmoserver_ext.rs | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/pmoapp/webapp/src/components/unified/ServerDrawer.vue b/pmoapp/webapp/src/components/unified/ServerDrawer.vue index 035d41e6..0ed350cf 100644 --- a/pmoapp/webapp/src/components/unified/ServerDrawer.vue +++ b/pmoapp/webapp/src/components/unified/ServerDrawer.vue @@ -289,8 +289,12 @@ function isPlayable(item: ContainerEntry): boolean { // Détermine si un container est navigable function isNavigable(item: ContainerEntry): boolean { - // Tous les containers sont navigables (on laisse le serveur décider si vide) - return item.is_container; + if (!item.is_container) return false; + // Une playlist avec un seul item : on garde le caractère jouable mais on retire + // la navigation pour éviter que l'utilisateur joue l'item directement et perde + // le bénéfice des mises à jour de métadonnées de la playlist. + if (isPlayable(item) && item.child_count === 1) return false; + return true; } function handleItemClick(item: ContainerEntry) { diff --git a/pmocontrol/src/media_server.rs b/pmocontrol/src/media_server.rs index d813d2d3..e82343f7 100644 --- a/pmocontrol/src/media_server.rs +++ b/pmocontrol/src/media_server.rs @@ -373,6 +373,7 @@ pub struct MediaEntry { pub is_container: bool, pub class: String, pub resources: Vec, + pub child_count: Option, pub artist: Option, pub album: Option, pub genre: Option, @@ -709,6 +710,7 @@ fn map_didl_entries(xml: &str) -> Result, ControlPointError> { is_container: true, class: container.class, resources: Vec::new(), + child_count: container.child_count.as_deref().and_then(|s| s.parse().ok()), artist: container.artist, album: None, genre: None, @@ -742,6 +744,7 @@ fn map_didl_entries(xml: &str) -> Result, ControlPointError> { is_container: false, class: item.class, resources, + child_count: None, artist: item.artist, album: item.album, genre: item.genre, diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index 883e9867..1bcfdce4 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -2211,7 +2211,7 @@ async fn browse_container( title: e.title, class: e.class, is_container: e.is_container, - child_count: None, + child_count: e.child_count, artist: e.artist, album: e.album, album_art_uri, @@ -2496,7 +2496,7 @@ async fn search_server( title: e.title, class: e.class, is_container: e.is_container, - child_count: None, + child_count: e.child_count, artist: e.artist, album: e.album, album_art_uri: e.album_art_uri, -- 2.49.1 From 7bce0ffe19a8f7b8e516a31bd48ad5592fa9f770 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 7 Jun 2026 14:56:44 +0200 Subject: [PATCH 5/6] Debounce transient snapshot fetch errors Introduce a `consecutiveSnapshotErrors` counter with a threshold of 3 to debounce transient fetch failures. The last known snapshot is preserved and UI notifications are suppressed until the threshold is met, filtering out intermittent device glitches. The counter resets on success. --- pmoapp/webapp/src/composables/useRenderers.ts | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/pmoapp/webapp/src/composables/useRenderers.ts b/pmoapp/webapp/src/composables/useRenderers.ts index d66177d4..2746ed83 100644 --- a/pmoapp/webapp/src/composables/useRenderers.ts +++ b/pmoapp/webapp/src/composables/useRenderers.ts @@ -27,6 +27,12 @@ const loadingIds = reactive(new Set()); const queueRefreshingIds = reactive(new Set()); const selectedRendererId = ref(null); +// Compteur d'erreurs consécutives par renderer — on ne notifie l'utilisateur +// qu'après SNAPSHOT_ERROR_THRESHOLD échecs d'affilée pour absorber les hoquets +// transitoires des devices (Arylic, etc.). +const consecutiveSnapshotErrors = new Map(); +const SNAPSHOT_ERROR_THRESHOLD = 3; + // Cache des renderers (summary) const renderersCache = ref>(new Map()); const RENDERERS_CACHE_MS = 2000; @@ -367,19 +373,25 @@ async function fetchRendererSnapshot( const snapshot = await api.getRendererFullSnapshot(rendererId); snapshots.set(rendererId, snapshot); lastSnapshotAt.set(rendererId, Date.now()); - + consecutiveSnapshotErrors.set(rendererId, 0); + } catch (err) { - console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err); - // En cas d'erreur, on supprime le snapshot pour permettre une nouvelle tentative - snapshots.delete(rendererId); - - // Notifier l'utilisateur - const name = renderersCache.value.get(rendererId)?.friendly_name ?? rendererId; - uiStore.notifyError(`Impossible de récupérer l'état de « ${name} »`); + const count = (consecutiveSnapshotErrors.get(rendererId) ?? 0) + 1; + consecutiveSnapshotErrors.set(rendererId, count); + console.error(`[useRenderers] Erreur snapshot ${rendererId} (${count}/${SNAPSHOT_ERROR_THRESHOLD}):`, err); + + // Garder le dernier snapshot connu plutôt que de le supprimer — l'UI reste + // affichable même si le device est momentanément muet. + + // Notifier seulement après N échecs consécutifs pour absorber les hoquets passagers + if (count >= SNAPSHOT_ERROR_THRESHOLD) { + const name = renderersCache.value.get(rendererId)?.friendly_name ?? rendererId; + uiStore.notifyError(`Impossible de récupérer l'état de « ${name} »`); + // Remettre à zéro pour ne pas spammer si le device reste instable + consecutiveSnapshotErrors.set(rendererId, 0); + } } finally { - // Toujours nettoyer le flag de chargement loadingIds.delete(rendererId); - } } -- 2.49.1 From 46e269a290e0fd7f7c05a970eb5d82104b0c9563 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 7 Jun 2026 15:10:54 +0200 Subject: [PATCH 6/6] fix: resume playback on non-empty queue regardless of transport state Removes the strict `transport_state` condition and now triggers `api.resume(id)` solely based on a non-empty queue. This improves resilience against transient or unknown device states, such as post-sleep-timer or network errors, where transport state strings may mismatch expected values but playback should still proceed. --- PMOMusic/Cargo.toml | 2 +- pmoapp/webapp/src/composables/useRenderers.ts | 10 +++++----- version.txt | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml index 18df9b9c..b37f9d90 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "PMOMusic" -version = "0.3.49" +version = "0.3.50" edition = "2024" [dependencies] diff --git a/pmoapp/webapp/src/composables/useRenderers.ts b/pmoapp/webapp/src/composables/useRenderers.ts index 2746ed83..8b3f5e2d 100644 --- a/pmoapp/webapp/src/composables/useRenderers.ts +++ b/pmoapp/webapp/src/composables/useRenderers.ts @@ -451,11 +451,11 @@ async function resumeOrPlayFromQueue(id: string) { return play(id); } - // Check if queue has content - if ( - ["STOPPED", "NO_MEDIA"].includes(state.transport_state) && - snapshot.queue.items.length > 0 - ) { + // If queue has items, always try to play from queue regardless of transport + // state. After a sleep timer stop, the device may report an unknown/transient + // state ("load", network error → "UNKNOWN", etc.) that doesn't match the + // expected "STOPPED"/"NO_MEDIA" strings, yet the queue is non-empty. + if (snapshot.queue.items.length > 0) { return api.resume(id); } diff --git a/version.txt b/version.txt index b3682c13..cd3dcaaf 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.3.49 +0.3.50 -- 2.49.1