6 Commits

Author SHA1 Message Date
46e269a290 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.
2026-06-07 15:13:03 +02:00
7bce0ffe19 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.
2026-06-07 14:57:51 +02:00
ba40d07f73 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.
2026-06-07 14:52:51 +02:00
8bc0670de4 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.
2026-06-07 14:41:37 +02:00
037435af20 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.
2026-06-07 14:27:15 +02:00
2b3fae7778 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.
2026-06-07 14:20:25 +02:00
10 changed files with 74 additions and 52 deletions

View File

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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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<string | null>(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 {

View File

@@ -27,6 +27,12 @@ const loadingIds = reactive(new Set<string>());
const queueRefreshingIds = reactive(new Set<string>());
const selectedRendererId = ref<string | null>(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<string, number>();
const SNAPSHOT_ERROR_THRESHOLD = 3;
// Cache des renderers (summary)
const renderersCache = ref<Map<string, RendererSummary>>(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);
const count = (consecutiveSnapshotErrors.get(rendererId) ?? 0) + 1;
consecutiveSnapshotErrors.set(rendererId, count);
console.error(`[useRenderers] Erreur snapshot ${rendererId} (${count}/${SNAPSHOT_ERROR_THRESHOLD}):`, err);
// Notifier l'utilisateur
const name = renderersCache.value.get(rendererId)?.friendly_name ?? rendererId;
uiStore.notifyError(`Impossible de récupérer l'état de « ${name} »`);
// 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);
}

View File

@@ -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::{

View File

@@ -373,6 +373,7 @@ pub struct MediaEntry {
pub is_container: bool,
pub class: String,
pub resources: Vec<MediaResource>,
pub child_count: Option<u32>,
pub artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
@@ -709,6 +710,7 @@ fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>, 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<Vec<MediaEntry>, ControlPointError> {
is_container: false,
class: item.class,
resources,
child_count: None,
artist: item.artist,
album: item.album,
genre: item.genre,

View File

@@ -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
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"

View File

@@ -1 +1 @@
0.3.49
0.3.50