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/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/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 { diff --git a/pmoapp/webapp/src/components/unified/ServerDrawer.vue b/pmoapp/webapp/src/components/unified/ServerDrawer.vue index 0f2783e0..0ed350cf 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); @@ -275,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) { @@ -1425,14 +1443,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 { diff --git a/pmoapp/webapp/src/composables/useRenderers.ts b/pmoapp/webapp/src/composables/useRenderers.ts index d66177d4..8b3f5e2d 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); - } } @@ -439,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/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/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, 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" 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