✨ 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:
6
package-lock.json
generated
6
package-lock.json
generated
@@ -2,5 +2,9 @@
|
|||||||
"name": "pmomusic",
|
"name": "pmomusic",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {}
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "pmomusic"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
pmoapp/webapp/package-lock.json
generated
6
pmoapp/webapp/package-lock.json
generated
@@ -1458,9 +1458,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, ref, watch, nextTick, toRef } from "vue";
|
import { computed, ref, watch, nextTick, toRef } from "vue";
|
||||||
import { useRenderer } from "@/composables/useRenderers";
|
import { useRenderer } from "@/composables/useRenderers";
|
||||||
import QueueItem from "./QueueItem.vue";
|
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";
|
import type { QueueItem as QueueItemType } from "@/services/pmocontrol/types";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -13,7 +13,7 @@ const emit = defineEmits<{
|
|||||||
clickItem: [item: QueueItemType];
|
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);
|
const isAttached = computed(() => !!binding.value);
|
||||||
|
|
||||||
@@ -72,6 +72,12 @@ watch(
|
|||||||
<Radio :size="16" />
|
<Radio :size="16" />
|
||||||
<span class="stream-text"> Web Radio </span>
|
<span class="stream-text"> Web Radio </span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,6 +168,33 @@ watch(
|
|||||||
font-size: var(--text-xs);
|
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 {
|
.queue-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface RendererSnapshotState {
|
|||||||
lastSnapshotAt: Map<string, number>;
|
lastSnapshotAt: Map<string, number>;
|
||||||
lastEventAt: Map<string, number>;
|
lastEventAt: Map<string, number>;
|
||||||
loadingIds: Set<string>;
|
loadingIds: Set<string>;
|
||||||
|
queueRefreshingIds: Set<string>;
|
||||||
selectedRendererId: string | null;
|
selectedRendererId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ const snapshotState = reactive<RendererSnapshotState>({
|
|||||||
lastSnapshotAt: reactive(new Map<string, number>()),
|
lastSnapshotAt: reactive(new Map<string, number>()),
|
||||||
lastEventAt: reactive(new Map<string, number>()),
|
lastEventAt: reactive(new Map<string, number>()),
|
||||||
loadingIds: reactive(new Set<string>()),
|
loadingIds: reactive(new Set<string>()),
|
||||||
|
queueRefreshingIds: reactive(new Set<string>()),
|
||||||
selectedRendererId: null,
|
selectedRendererId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,8 +192,13 @@ function ensureSSEConnected() {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "queue_refreshing":
|
||||||
|
snapshotState.queueRefreshingIds.add(rendererId);
|
||||||
|
break;
|
||||||
|
|
||||||
case "queue_updated":
|
case "queue_updated":
|
||||||
snapshot.state.queue_len = event.queue_length;
|
snapshot.state.queue_len = event.queue_length;
|
||||||
|
snapshotState.queueRefreshingIds.delete(rendererId);
|
||||||
// Pour la queue complète, on doit refetch
|
// Pour la queue complète, on doit refetch
|
||||||
void fetchRendererSnapshot(rendererId, { force: true });
|
void fetchRendererSnapshot(rendererId, { force: true });
|
||||||
break;
|
break;
|
||||||
@@ -268,6 +275,10 @@ function isSnapshotLoading(id: string) {
|
|||||||
return snapshotState.loadingIds.has(id);
|
return snapshotState.loadingIds.has(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isQueueRefreshing(id: string) {
|
||||||
|
return snapshotState.queueRefreshingIds.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
function selectRenderer(id: string | null) {
|
function selectRenderer(id: string | null) {
|
||||||
snapshotState.selectedRendererId = id;
|
snapshotState.selectedRendererId = id;
|
||||||
}
|
}
|
||||||
@@ -454,6 +465,7 @@ export function useRenderers() {
|
|||||||
getQueueById,
|
getQueueById,
|
||||||
getBindingById,
|
getBindingById,
|
||||||
isSnapshotLoading,
|
isSnapshotLoading,
|
||||||
|
isQueueRefreshing,
|
||||||
selectRenderer,
|
selectRenderer,
|
||||||
snapshotState,
|
snapshotState,
|
||||||
// Fetchers
|
// Fetchers
|
||||||
@@ -492,6 +504,9 @@ export function useRenderer(rendererId: Ref<string>) {
|
|||||||
const queue = computed(() => snapshot.value?.queue ?? null);
|
const queue = computed(() => snapshot.value?.queue ?? null);
|
||||||
const binding = computed(() => snapshot.value?.binding ?? null);
|
const binding = computed(() => snapshot.value?.binding ?? null);
|
||||||
const isStream = computed(() => snapshot.value?.is_stream ?? false);
|
const isStream = computed(() => snapshot.value?.is_stream ?? false);
|
||||||
|
const queueRefreshing = computed(() =>
|
||||||
|
snapshotState.queueRefreshingIds.has(rendererId.value),
|
||||||
|
);
|
||||||
|
|
||||||
async function refresh(force = true) {
|
async function refresh(force = true) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -507,6 +522,7 @@ export function useRenderer(rendererId: Ref<string>) {
|
|||||||
queue,
|
queue,
|
||||||
binding,
|
binding,
|
||||||
isStream,
|
isStream,
|
||||||
|
queueRefreshing,
|
||||||
refresh,
|
refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,11 @@ export type RendererEventPayload =
|
|||||||
queue_length: number;
|
queue_length: number;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "queue_refreshing";
|
||||||
|
renderer_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "binding_changed";
|
type: "binding_changed";
|
||||||
renderer_id: string;
|
renderer_id: string;
|
||||||
|
|||||||
@@ -1585,10 +1585,9 @@ fn refresh_attached_queue_for(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Notify UI that the renderer is loading (Transitioning state)
|
// Step 3: Notify UI that the queue is being refreshed (dedicated event, no state change)
|
||||||
event_bus.broadcast(RendererEvent::StateChanged {
|
event_bus.broadcast(RendererEvent::QueueRefreshing {
|
||||||
id: renderer_id.clone(),
|
id: renderer_id.clone(),
|
||||||
state: PlaybackState::Transitioning,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 4: Browse container (renamed from Step 3 for clarity)
|
// Step 4: Browse container (renamed from Step 3 for clarity)
|
||||||
|
|||||||
@@ -434,6 +434,9 @@ pub enum RendererEvent {
|
|||||||
id: DeviceId,
|
id: DeviceId,
|
||||||
queue_length: usize,
|
queue_length: usize,
|
||||||
},
|
},
|
||||||
|
QueueRefreshing {
|
||||||
|
id: DeviceId,
|
||||||
|
},
|
||||||
BindingChanged {
|
BindingChanged {
|
||||||
id: DeviceId,
|
id: DeviceId,
|
||||||
binding: Option<PlaylistBinding>,
|
binding: Option<PlaylistBinding>,
|
||||||
|
|||||||
@@ -647,6 +647,17 @@ impl OpenHomeQueue {
|
|||||||
let snapshot = self.queue_snapshot()?;
|
let snapshot = self.queue_snapshot()?;
|
||||||
let current_track_ids = self.track_ids()?;
|
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 (keep_current, keep_desired) = lcs_flags(&snapshot.items, &items);
|
||||||
|
|
||||||
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
|
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ pub enum RendererEventPayload {
|
|||||||
queue_length: usize,
|
queue_length: usize,
|
||||||
timestamp: chrono::DateTime<chrono::Utc>,
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
},
|
},
|
||||||
|
QueueRefreshing {
|
||||||
|
renderer_id: String,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
BindingChanged {
|
BindingChanged {
|
||||||
renderer_id: String,
|
renderer_id: String,
|
||||||
server_id: Option<String>,
|
server_id: Option<String>,
|
||||||
@@ -214,6 +218,10 @@ fn renderer_event_to_payload(
|
|||||||
queue_length,
|
queue_length,
|
||||||
timestamp,
|
timestamp,
|
||||||
},
|
},
|
||||||
|
RendererEvent::QueueRefreshing { id } => RendererEventPayload::QueueRefreshing {
|
||||||
|
renderer_id: id.0,
|
||||||
|
timestamp,
|
||||||
|
},
|
||||||
RendererEvent::BindingChanged { id, binding } => RendererEventPayload::BindingChanged {
|
RendererEvent::BindingChanged { id, binding } => RendererEventPayload::BindingChanged {
|
||||||
renderer_id: id.0,
|
renderer_id: id.0,
|
||||||
server_id: binding.as_ref().map(|b| b.server_id.0.clone()),
|
server_id: binding.as_ref().map(|b| b.server_id.0.clone()),
|
||||||
|
|||||||
Reference in New Issue
Block a user