diff --git a/package-lock.json b/package-lock.json
index a13a2658..254f4e73 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2,5 +2,9 @@
"name": "pmomusic",
"lockfileVersion": 3,
"requires": true,
- "packages": {}
+ "packages": {
+ "": {
+ "name": "pmomusic"
+ }
+ }
}
diff --git a/pmoapp/webapp/package-lock.json b/pmoapp/webapp/package-lock.json
index 220494aa..1c4d70b6 100644
--- a/pmoapp/webapp/package-lock.json
+++ b/pmoapp/webapp/package-lock.json
@@ -1458,9 +1458,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue b/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue
index 6388ce12..49bd8e82 100644
--- a/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue
+++ b/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue
@@ -2,7 +2,7 @@
import { computed, ref, watch, nextTick, toRef } from "vue";
import { useRenderer } from "@/composables/useRenderers";
import QueueItem from "./QueueItem.vue";
-import { Link, Radio } from "lucide-vue-next";
+import { Link, Radio, RefreshCw } from "lucide-vue-next";
import type { QueueItem as QueueItemType } from "@/services/pmocontrol/types";
const props = defineProps<{
@@ -13,7 +13,7 @@ const emit = defineEmits<{
clickItem: [item: QueueItemType];
}>();
-const { queue, binding, isStream } = useRenderer(toRef(props, "rendererId"));
+const { queue, binding, isStream, queueRefreshing } = useRenderer(toRef(props, "rendererId"));
const isAttached = computed(() => !!binding.value);
@@ -72,6 +72,12 @@ watch(
Web Radio
+
+
+
+
+ Mise à jour...
+
@@ -162,6 +168,33 @@ watch(
font-size: var(--text-xs);
}
+.refresh-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ background-color: var(--color-bg-secondary);
+ color: var(--color-text-secondary);
+ border-radius: var(--radius-md);
+ font-size: var(--text-sm);
+ font-weight: 500;
+ border: 1px solid var(--color-border);
+ width: fit-content;
+}
+
+.refresh-icon {
+ animation: spin 1s linear infinite;
+}
+
+.refresh-text {
+ font-size: var(--text-xs);
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
.queue-list {
flex: 1;
overflow-y: auto;
diff --git a/pmoapp/webapp/src/composables/useRenderers.ts b/pmoapp/webapp/src/composables/useRenderers.ts
index a9947164..e607e8d9 100644
--- a/pmoapp/webapp/src/composables/useRenderers.ts
+++ b/pmoapp/webapp/src/composables/useRenderers.ts
@@ -20,6 +20,7 @@ interface RendererSnapshotState {
lastSnapshotAt: Map;
lastEventAt: Map;
loadingIds: Set;
+ queueRefreshingIds: Set;
selectedRendererId: string | null;
}
@@ -32,6 +33,7 @@ const snapshotState = reactive({
lastSnapshotAt: reactive(new Map()),
lastEventAt: reactive(new Map()),
loadingIds: reactive(new Set()),
+ queueRefreshingIds: reactive(new Set()),
selectedRendererId: null,
});
@@ -190,8 +192,13 @@ function ensureSSEConnected() {
});
break;
+ case "queue_refreshing":
+ snapshotState.queueRefreshingIds.add(rendererId);
+ break;
+
case "queue_updated":
snapshot.state.queue_len = event.queue_length;
+ snapshotState.queueRefreshingIds.delete(rendererId);
// Pour la queue complète, on doit refetch
void fetchRendererSnapshot(rendererId, { force: true });
break;
@@ -268,6 +275,10 @@ function isSnapshotLoading(id: string) {
return snapshotState.loadingIds.has(id);
}
+function isQueueRefreshing(id: string) {
+ return snapshotState.queueRefreshingIds.has(id);
+}
+
function selectRenderer(id: string | null) {
snapshotState.selectedRendererId = id;
}
@@ -454,6 +465,7 @@ export function useRenderers() {
getQueueById,
getBindingById,
isSnapshotLoading,
+ isQueueRefreshing,
selectRenderer,
snapshotState,
// Fetchers
@@ -492,6 +504,9 @@ export function useRenderer(rendererId: Ref) {
const queue = computed(() => snapshot.value?.queue ?? null);
const binding = computed(() => snapshot.value?.binding ?? null);
const isStream = computed(() => snapshot.value?.is_stream ?? false);
+ const queueRefreshing = computed(() =>
+ snapshotState.queueRefreshingIds.has(rendererId.value),
+ );
async function refresh(force = true) {
await Promise.all([
@@ -507,6 +522,7 @@ export function useRenderer(rendererId: Ref) {
queue,
binding,
isStream,
+ queueRefreshing,
refresh,
};
}
diff --git a/pmoapp/webapp/src/services/pmocontrol/types.ts b/pmoapp/webapp/src/services/pmocontrol/types.ts
index 527b57ab..3c8b1daf 100644
--- a/pmoapp/webapp/src/services/pmocontrol/types.ts
+++ b/pmoapp/webapp/src/services/pmocontrol/types.ts
@@ -197,6 +197,11 @@ export type RendererEventPayload =
queue_length: number;
timestamp: string;
}
+ | {
+ type: "queue_refreshing";
+ renderer_id: string;
+ timestamp: string;
+ }
| {
type: "binding_changed";
renderer_id: string;
diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs
index 9a5e0dc3..877117d2 100644
--- a/pmocontrol/src/control_point.rs
+++ b/pmocontrol/src/control_point.rs
@@ -1585,10 +1585,9 @@ fn refresh_attached_queue_for(
return Ok(());
}
- // Step 3: Notify UI that the renderer is loading (Transitioning state)
- event_bus.broadcast(RendererEvent::StateChanged {
+ // Step 3: Notify UI that the queue is being refreshed (dedicated event, no state change)
+ event_bus.broadcast(RendererEvent::QueueRefreshing {
id: renderer_id.clone(),
- state: PlaybackState::Transitioning,
});
// Step 4: Browse container (renamed from Step 3 for clarity)
diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs
index 1bd6dd7c..9686d3a9 100644
--- a/pmocontrol/src/model.rs
+++ b/pmocontrol/src/model.rs
@@ -434,6 +434,9 @@ pub enum RendererEvent {
id: DeviceId,
queue_length: usize,
},
+ QueueRefreshing {
+ id: DeviceId,
+ },
BindingChanged {
id: DeviceId,
binding: Option,
diff --git a/pmocontrol/src/queue/openhome.rs b/pmocontrol/src/queue/openhome.rs
index dc34859a..d7047d5a 100644
--- a/pmocontrol/src/queue/openhome.rs
+++ b/pmocontrol/src/queue/openhome.rs
@@ -647,6 +647,17 @@ impl OpenHomeQueue {
let snapshot = self.queue_snapshot()?;
let current_track_ids = self.track_ids()?;
+ debug!(
+ renderer = self.renderer_id.0.as_str(),
+ current_count = snapshot.items.len(),
+ desired_count = items.len(),
+ current_uris = ?snapshot.items.iter().map(|i| i.uri.as_str()).collect::>(),
+ current_didl_ids = ?snapshot.items.iter().map(|i| i.didl_id.as_str()).collect::>(),
+ desired_uris = ?items.iter().map(|i| i.uri.as_str()).collect::>(),
+ desired_didl_ids = ?items.iter().map(|i| i.didl_id.as_str()).collect::>(),
+ "LCS input: current vs desired items"
+ );
+
let (keep_current, keep_desired) = lcs_flags(&snapshot.items, &items);
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
diff --git a/pmocontrol/src/sse.rs b/pmocontrol/src/sse.rs
index f2a1c79d..d5448592 100644
--- a/pmocontrol/src/sse.rs
+++ b/pmocontrol/src/sse.rs
@@ -79,6 +79,10 @@ pub enum RendererEventPayload {
queue_length: usize,
timestamp: chrono::DateTime,
},
+ QueueRefreshing {
+ renderer_id: String,
+ timestamp: chrono::DateTime,
+ },
BindingChanged {
renderer_id: String,
server_id: Option,
@@ -214,6 +218,10 @@ fn renderer_event_to_payload(
queue_length,
timestamp,
},
+ RendererEvent::QueueRefreshing { id } => RendererEventPayload::QueueRefreshing {
+ renderer_id: id.0,
+ timestamp,
+ },
RendererEvent::BindingChanged { id, binding } => RendererEventPayload::BindingChanged {
renderer_id: id.0,
server_id: binding.as_ref().map(|b| b.server_id.0.clone()),