Add queue refreshing indicator to UI

- Introduce `QueueRefreshing` event type for dedicated queue refresh state
- Add reactive tracking of refreshing queues via new `queueRefreshingIds` Set in useRenderers composable
- Display visual indicator (spinning icon + text) when queue is refreshing in QueueViewer component
- Update `picomatch` dependency to v4.0.4 (patch)
- Minor cleanup in package-lock.json files
This commit is contained in:
2026-04-03 20:45:35 +02:00
parent 494cc3b8e1
commit 2fd4c84e1a
9 changed files with 88 additions and 9 deletions

6
package-lock.json generated
View File

@@ -2,5 +2,9 @@
"name": "pmomusic",
"lockfileVersion": 3,
"requires": true,
"packages": {}
"packages": {
"": {
"name": "pmomusic"
}
}
}

View File

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

View File

@@ -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(
<Radio :size="16" />
<span class="stream-text"> Web Radio </span>
</div>
<!-- Indicateur de mise à jour de queue -->
<div v-if="queueRefreshing" class="refresh-indicator">
<RefreshCw :size="14" class="refresh-icon" />
<span class="refresh-text"> Mise à jour... </span>
</div>
</div>
</div>
@@ -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;

View File

@@ -20,6 +20,7 @@ interface RendererSnapshotState {
lastSnapshotAt: Map<string, number>;
lastEventAt: Map<string, number>;
loadingIds: Set<string>;
queueRefreshingIds: Set<string>;
selectedRendererId: string | null;
}
@@ -32,6 +33,7 @@ const snapshotState = reactive<RendererSnapshotState>({
lastSnapshotAt: reactive(new Map<string, number>()),
lastEventAt: reactive(new Map<string, number>()),
loadingIds: reactive(new Set<string>()),
queueRefreshingIds: reactive(new Set<string>()),
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<string>) {
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<string>) {
queue,
binding,
isStream,
queueRefreshing,
refresh,
};
}

View File

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

View File

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

View File

@@ -434,6 +434,9 @@ pub enum RendererEvent {
id: DeviceId,
queue_length: usize,
},
QueueRefreshing {
id: DeviceId,
},
BindingChanged {
id: DeviceId,
binding: Option<PlaylistBinding>,

View File

@@ -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::<Vec<_>>(),
current_didl_ids = ?snapshot.items.iter().map(|i| i.didl_id.as_str()).collect::<Vec<_>>(),
desired_uris = ?items.iter().map(|i| i.uri.as_str()).collect::<Vec<_>>(),
desired_didl_ids = ?items.iter().map(|i| i.didl_id.as_str()).collect::<Vec<_>>(),
"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();

View File

@@ -79,6 +79,10 @@ pub enum RendererEventPayload {
queue_length: usize,
timestamp: chrono::DateTime<chrono::Utc>,
},
QueueRefreshing {
renderer_id: String,
timestamp: chrono::DateTime<chrono::Utc>,
},
BindingChanged {
renderer_id: String,
server_id: Option<String>,
@@ -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()),