Merge pull request 'push-zusoquuoxstx' (#99) from push-zusoquuoxstx into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m35s

Reviewed-on: #99
This commit was merged in pull request #99.
This commit is contained in:
2026-06-07 15:14:04 +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
// 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