2025-12-05 23:37:21 +01:00
|
|
|
/**
|
2025-12-06 00:38:06 +01:00
|
|
|
* Composable pour gérer les renderers.
|
|
|
|
|
* Le ControlPoint est la seule source de vérité :
|
|
|
|
|
* - Les snapshots complets proviennent de /renderers/{id}/full
|
|
|
|
|
* - Les événements SSE ne servent qu'à déclencher un refetch.
|
2025-12-05 23:37:21 +01:00
|
|
|
*/
|
2026-04-06 09:50:54 +02:00
|
|
|
import { ref, reactive, computed, type Ref, onUnmounted } from "vue";
|
2026-01-09 14:30:53 +01:00
|
|
|
import { api } from "../services/pmocontrol/api";
|
2026-04-03 22:26:54 +02:00
|
|
|
import { useSSE } from "./useSSE";
|
2026-04-03 22:42:12 +02:00
|
|
|
import { apiCache } from "./apiCache";
|
2026-04-03 22:52:48 +02:00
|
|
|
import { parseTimeToMs } from "../utils/time";
|
2026-04-06 08:47:22 +02:00
|
|
|
import { useUIStore } from "@/stores/ui";
|
2025-12-05 23:37:21 +01:00
|
|
|
import type {
|
|
|
|
|
RendererSummary,
|
|
|
|
|
RendererState,
|
|
|
|
|
QueueSnapshot,
|
2025-12-06 00:38:06 +01:00
|
|
|
AttachedPlaylistInfo,
|
|
|
|
|
FullRendererSnapshot,
|
2026-01-09 14:30:53 +01:00
|
|
|
} from "../services/pmocontrol/types";
|
2026-04-06 08:47:22 +02:00
|
|
|
import { isTransportState } from "../services/pmocontrol/types";
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2026-04-06 09:50:54 +02:00
|
|
|
// État global des snapshots avec reactive pour une réactivité native Vue sur les Maps
|
|
|
|
|
const snapshots = reactive(new Map<string, FullRendererSnapshot>());
|
|
|
|
|
const lastSnapshotAt = reactive(new Map<string, number>());
|
|
|
|
|
const lastEventAt = reactive(new Map<string, number>());
|
|
|
|
|
const loadingIds = reactive(new Set<string>());
|
|
|
|
|
const queueRefreshingIds = reactive(new Set<string>());
|
2026-04-06 08:47:22 +02:00
|
|
|
const selectedRendererId = ref<string | null>(null);
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2026-06-07 14:56:44 +02:00
|
|
|
// 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;
|
|
|
|
|
|
2026-04-06 08:47:22 +02:00
|
|
|
// Cache des renderers (summary)
|
2026-01-09 14:30:53 +01:00
|
|
|
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
|
|
|
|
const RENDERERS_CACHE_MS = 2000;
|
2025-12-06 00:38:06 +01:00
|
|
|
|
2026-04-06 09:50:54 +02:00
|
|
|
// Supprimé : les helpers triggerXXX ne sont plus nécessaires avec reactive
|
2026-04-06 08:56:28 +02:00
|
|
|
|
2026-01-09 14:30:53 +01:00
|
|
|
const loading = ref(false);
|
|
|
|
|
const error = ref<string | null>(null);
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2026-04-03 22:26:54 +02:00
|
|
|
// Utiliser le composable SSE centralisé
|
|
|
|
|
let sseInitialized = false;
|
2026-04-06 08:47:22 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Réinitialise le flag SSE pour permettre une nouvelle connexion après reconnexion
|
|
|
|
|
*/
|
|
|
|
|
function resetSSE() {
|
|
|
|
|
sseInitialized = false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 22:26:54 +02:00
|
|
|
function ensureSSEInitialized() {
|
|
|
|
|
if (sseInitialized) return;
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2026-04-03 22:26:54 +02:00
|
|
|
const { onRendererEvent, connect } = useSSE();
|
|
|
|
|
|
|
|
|
|
// Démarrer la connexion SSE
|
|
|
|
|
connect();
|
|
|
|
|
|
|
|
|
|
onRendererEvent((event) => {
|
2026-01-09 14:30:53 +01:00
|
|
|
const rendererId = event.renderer_id;
|
|
|
|
|
const timestamp = Date.parse(event.timestamp ?? "") || Date.now();
|
2025-12-26 20:02:09 +01:00
|
|
|
|
|
|
|
|
// Gérer les événements Online/Offline différemment
|
2026-01-09 14:30:53 +01:00
|
|
|
if (event.type === "online") {
|
2025-12-26 20:02:09 +01:00
|
|
|
// Ajouter au cache avec les infos disponibles
|
|
|
|
|
// Note: on n'a pas toutes les infos (capabilities, protocol) donc on fetch ensuite
|
|
|
|
|
const renderer: RendererSummary = {
|
|
|
|
|
id: rendererId,
|
|
|
|
|
friendly_name: event.friendly_name,
|
|
|
|
|
model_name: event.model_name,
|
2026-04-05 23:42:25 +02:00
|
|
|
manufacturer: event.manufacturer,
|
2026-01-09 14:30:53 +01:00
|
|
|
protocol: "upnp", // Valeur par défaut, sera mise à jour par le fetch
|
2025-12-26 20:02:09 +01:00
|
|
|
capabilities: {
|
|
|
|
|
has_avtransport: false,
|
|
|
|
|
has_avtransport_set_next: false,
|
|
|
|
|
has_rendering_control: false,
|
|
|
|
|
has_connection_manager: false,
|
|
|
|
|
has_linkplay_http: false,
|
|
|
|
|
has_arylic_tcp: false,
|
|
|
|
|
has_oh_playlist: false,
|
|
|
|
|
has_oh_volume: false,
|
|
|
|
|
has_oh_info: false,
|
|
|
|
|
has_oh_time: false,
|
|
|
|
|
has_oh_radio: false,
|
|
|
|
|
},
|
|
|
|
|
online: true,
|
2026-01-09 14:30:53 +01:00
|
|
|
};
|
|
|
|
|
renderersCache.value.set(rendererId, renderer);
|
2025-12-26 20:02:09 +01:00
|
|
|
|
|
|
|
|
// Fetch la liste complète pour avoir les bonnes infos
|
2026-01-09 14:30:53 +01:00
|
|
|
void fetchRenderers(true);
|
2025-12-26 20:02:09 +01:00
|
|
|
|
|
|
|
|
// Fetch le snapshot complet pour ce renderer
|
2026-01-09 14:30:53 +01:00
|
|
|
void fetchRendererSnapshot(rendererId, { force: true });
|
|
|
|
|
return;
|
2025-12-26 20:02:09 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-09 14:30:53 +01:00
|
|
|
if (event.type === "offline") {
|
2025-12-26 20:02:09 +01:00
|
|
|
// Marquer comme offline dans le cache
|
2026-01-09 14:30:53 +01:00
|
|
|
const renderer = renderersCache.value.get(rendererId);
|
2025-12-26 20:02:09 +01:00
|
|
|
if (renderer) {
|
2026-01-09 14:30:53 +01:00
|
|
|
renderer.online = false;
|
|
|
|
|
renderersCache.value.set(rendererId, renderer);
|
2025-12-26 20:02:09 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Supprimer le snapshot (il n'est plus valide)
|
2026-04-06 09:50:54 +02:00
|
|
|
snapshots.delete(rendererId);
|
|
|
|
|
lastSnapshotAt.delete(rendererId);
|
|
|
|
|
lastEventAt.delete(rendererId);
|
2026-01-09 14:30:53 +01:00
|
|
|
return;
|
2025-12-26 20:02:09 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-11 21:19:01 +01:00
|
|
|
// Pour les autres événements, mettre à jour le snapshot local directement
|
2026-04-06 09:50:54 +02:00
|
|
|
lastEventAt.set(rendererId, timestamp);
|
2026-01-11 21:19:01 +01:00
|
|
|
|
2026-04-06 09:50:54 +02:00
|
|
|
const snapshot = snapshots.get(rendererId);
|
2026-01-11 21:19:01 +01:00
|
|
|
|
|
|
|
|
// Si pas de snapshot, on doit fetch
|
|
|
|
|
if (!snapshot) {
|
2026-01-09 14:30:53 +01:00
|
|
|
void fetchRendererSnapshot(rendererId, { force: true });
|
2026-01-11 21:19:01 +01:00
|
|
|
return;
|
2025-12-05 23:37:21 +01:00
|
|
|
}
|
2026-01-11 21:19:01 +01:00
|
|
|
|
|
|
|
|
// Sinon, mettre à jour le snapshot localement selon le type d'événement
|
|
|
|
|
switch (event.type) {
|
|
|
|
|
case "state_changed":
|
2026-04-06 08:56:28 +02:00
|
|
|
// Créer un nouvel objet pour déclencher la réactivité
|
2026-04-06 08:47:22 +02:00
|
|
|
if (isTransportState(event.state)) {
|
2026-04-06 09:50:54 +02:00
|
|
|
snapshots.set(rendererId, {
|
2026-04-06 08:56:28 +02:00
|
|
|
...snapshot,
|
|
|
|
|
state: { ...snapshot.state, transport_state: event.state },
|
|
|
|
|
});
|
2026-04-06 09:50:54 +02:00
|
|
|
|
2026-04-06 08:47:22 +02:00
|
|
|
} else {
|
|
|
|
|
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
|
|
|
|
|
}
|
2026-01-11 21:19:01 +01:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case "position_changed":
|
2026-01-31 13:11:55 +01:00
|
|
|
// Mettre à jour position et durée de manière atomique pour garantir la cohérence
|
|
|
|
|
// Le backend envoie TOUJOURS les deux valeurs (même si null)
|
|
|
|
|
|
2026-01-11 21:19:01 +01:00
|
|
|
// Convertir rel_time (HH:MM:SS) en millisecondes
|
2026-04-03 22:52:48 +02:00
|
|
|
const positionMs = parseTimeToMs(event.rel_time ?? null);
|
2026-01-31 13:11:55 +01:00
|
|
|
|
2026-01-11 21:19:01 +01:00
|
|
|
// Convertir track_duration (HH:MM:SS) en millisecondes
|
2026-04-03 22:52:48 +02:00
|
|
|
const durationMs = parseTimeToMs(event.track_duration ?? null);
|
2026-04-06 08:56:28 +02:00
|
|
|
|
|
|
|
|
// Créer un nouvel objet pour déclencher la réactivité
|
2026-04-06 09:50:54 +02:00
|
|
|
snapshots.set(rendererId, {
|
2026-01-31 19:56:46 +01:00
|
|
|
...snapshot,
|
2026-04-06 08:56:28 +02:00
|
|
|
state: {
|
|
|
|
|
...snapshot.state,
|
|
|
|
|
position_ms: positionMs ?? 0,
|
|
|
|
|
duration_ms: durationMs,
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-04-06 09:50:54 +02:00
|
|
|
|
2026-01-11 21:19:01 +01:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case "volume_changed":
|
2026-04-06 08:47:22 +02:00
|
|
|
// Créer un nouvel objet pour déclencher la réactivité
|
2026-04-06 09:50:54 +02:00
|
|
|
snapshots.set(rendererId, {
|
2026-04-06 08:47:22 +02:00
|
|
|
...snapshot,
|
|
|
|
|
state: { ...snapshot.state, volume: event.volume },
|
|
|
|
|
});
|
2026-01-11 21:19:01 +01:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case "mute_changed":
|
2026-04-06 08:47:22 +02:00
|
|
|
// Créer un nouvel objet pour déclencher la réactivité
|
2026-04-06 09:50:54 +02:00
|
|
|
snapshots.set(rendererId, {
|
2026-04-06 08:47:22 +02:00
|
|
|
...snapshot,
|
|
|
|
|
state: { ...snapshot.state, mute: event.mute },
|
|
|
|
|
});
|
2026-01-11 21:19:01 +01:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case "metadata_changed":
|
|
|
|
|
if (!snapshot.state.current_track) {
|
|
|
|
|
snapshot.state.current_track = {
|
|
|
|
|
title: null,
|
|
|
|
|
artist: null,
|
|
|
|
|
album: null,
|
|
|
|
|
album_art_uri: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
snapshot.state.current_track.title = event.title;
|
|
|
|
|
snapshot.state.current_track.artist = event.artist;
|
|
|
|
|
snapshot.state.current_track.album = event.album;
|
|
|
|
|
snapshot.state.current_track.album_art_uri = event.album_art_uri;
|
2026-01-31 19:56:46 +01:00
|
|
|
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
|
2026-04-06 09:50:54 +02:00
|
|
|
snapshots.set(rendererId, {
|
2026-01-31 19:56:46 +01:00
|
|
|
...snapshot,
|
|
|
|
|
state: { ...snapshot.state },
|
|
|
|
|
});
|
2026-01-11 21:19:01 +01:00
|
|
|
break;
|
|
|
|
|
|
2026-04-03 20:45:35 +02:00
|
|
|
case "queue_refreshing":
|
2026-04-06 09:50:54 +02:00
|
|
|
queueRefreshingIds.add(rendererId);
|
|
|
|
|
|
2026-04-03 20:45:35 +02:00
|
|
|
break;
|
|
|
|
|
|
2026-01-11 21:19:01 +01:00
|
|
|
case "queue_updated":
|
|
|
|
|
snapshot.state.queue_len = event.queue_length;
|
2026-04-06 09:50:54 +02:00
|
|
|
queueRefreshingIds.delete(rendererId);
|
|
|
|
|
|
2026-04-09 19:33:58 +02:00
|
|
|
// Fetch queue items immediately when we get queue_updated
|
|
|
|
|
// The debounce was causing race conditions where queue_len was updated
|
|
|
|
|
// but items weren't fetched yet when user clicked play
|
|
|
|
|
void fetchRendererSnapshot(rendererId, { force: true });
|
2026-01-11 21:19:01 +01:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case "binding_changed":
|
|
|
|
|
if (event.server_id && event.container_id) {
|
|
|
|
|
snapshot.binding = {
|
|
|
|
|
server_id: event.server_id,
|
|
|
|
|
container_id: event.container_id,
|
|
|
|
|
has_seen_update: false,
|
|
|
|
|
};
|
|
|
|
|
snapshot.state.attached_playlist = snapshot.binding;
|
|
|
|
|
} else {
|
|
|
|
|
snapshot.binding = null;
|
|
|
|
|
snapshot.state.attached_playlist = null;
|
|
|
|
|
}
|
2026-04-06 08:47:22 +02:00
|
|
|
// Créer un nouvel objet pour déclencher la réactivité
|
2026-04-06 09:50:54 +02:00
|
|
|
snapshots.set(rendererId, {
|
2026-04-06 08:47:22 +02:00
|
|
|
...snapshot,
|
|
|
|
|
state: { ...snapshot.state },
|
|
|
|
|
});
|
2026-01-11 21:19:01 +01:00
|
|
|
break;
|
|
|
|
|
|
2026-01-31 11:44:23 +01:00
|
|
|
case "stream_state_changed":
|
|
|
|
|
snapshot.is_stream = event.is_stream;
|
2026-04-06 08:47:22 +02:00
|
|
|
// Créer un nouvel objet pour déclencher la réactivité
|
2026-04-06 09:50:54 +02:00
|
|
|
snapshots.set(rendererId, { ...snapshot });
|
2026-01-31 11:44:23 +01:00
|
|
|
break;
|
|
|
|
|
|
2026-01-11 21:19:01 +01:00
|
|
|
case "timer_started":
|
|
|
|
|
case "timer_updated":
|
|
|
|
|
case "timer_tick":
|
|
|
|
|
case "timer_expired":
|
|
|
|
|
case "timer_cancelled":
|
|
|
|
|
// Les événements de timer ne modifient pas le snapshot renderer
|
|
|
|
|
// (le timer state est géré séparément)
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 08:47:22 +02:00
|
|
|
// Note: chaque case est maintenant responsable de stocker le snapshot dans la Map
|
|
|
|
|
// Plus de réassignation finale après le switch
|
2026-01-09 14:30:53 +01:00
|
|
|
});
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2026-04-03 22:26:54 +02:00
|
|
|
sseInitialized = true;
|
2025-12-05 23:37:21 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-09 14:30:53 +01:00
|
|
|
const allRenderers = computed(() => Array.from(renderersCache.value.values()));
|
|
|
|
|
const onlineRenderers = computed(() =>
|
|
|
|
|
allRenderers.value.filter((r) => r.online),
|
|
|
|
|
);
|
|
|
|
|
const allSnapshots = computed(() =>
|
2026-04-06 09:50:54 +02:00
|
|
|
Array.from(snapshots.values()),
|
2026-01-09 14:30:53 +01:00
|
|
|
);
|
2025-12-06 00:38:06 +01:00
|
|
|
const playingRenderers = computed(() =>
|
|
|
|
|
allSnapshots.value
|
2026-01-09 14:30:53 +01:00
|
|
|
.filter((snapshot) => snapshot.state.transport_state === "PLAYING")
|
2025-12-06 00:38:06 +01:00
|
|
|
.map((snapshot) => snapshot.state),
|
2026-01-09 14:30:53 +01:00
|
|
|
);
|
2025-12-06 00:38:06 +01:00
|
|
|
|
|
|
|
|
function getRendererById(id: string) {
|
2026-01-09 14:30:53 +01:00
|
|
|
return renderersCache.value.get(id);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
function getSnapshotById(id: string) {
|
2026-04-06 09:50:54 +02:00
|
|
|
return snapshots.get(id) ?? null;
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
function getStateById(id: string): RendererState | null {
|
2026-04-06 09:50:54 +02:00
|
|
|
return snapshots.get(id)?.state ?? null;
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
function getQueueById(id: string): QueueSnapshot | null {
|
2026-04-06 09:50:54 +02:00
|
|
|
return snapshots.get(id)?.queue ?? null;
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
function getBindingById(id: string): AttachedPlaylistInfo | null {
|
2026-04-06 09:50:54 +02:00
|
|
|
return snapshots.get(id)?.binding ?? null;
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
function isSnapshotLoading(id: string) {
|
2026-04-06 09:50:54 +02:00
|
|
|
return loadingIds.has(id);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2026-04-03 20:45:35 +02:00
|
|
|
function isQueueRefreshing(id: string) {
|
2026-04-06 09:50:54 +02:00
|
|
|
return queueRefreshingIds.has(id);
|
2026-04-03 20:45:35 +02:00
|
|
|
}
|
|
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
function selectRenderer(id: string | null) {
|
2026-04-06 08:47:22 +02:00
|
|
|
selectedRendererId.value = id;
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2026-04-06 00:08:50 +02:00
|
|
|
async function fetchRenderers(force = false, retries = 2) {
|
2026-04-03 22:26:54 +02:00
|
|
|
ensureSSEInitialized();
|
2026-04-06 08:47:22 +02:00
|
|
|
|
|
|
|
|
// Créer le store UI pour les notifications (lazy import pour éviter les effets de bord)
|
|
|
|
|
const uiStore = useUIStore();
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2026-04-06 00:08:50 +02:00
|
|
|
let lastError: Error | null = null;
|
|
|
|
|
|
|
|
|
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
|
|
|
try {
|
|
|
|
|
loading.value = true;
|
|
|
|
|
error.value = null;
|
|
|
|
|
|
|
|
|
|
// Utiliser le cache API centralisé
|
|
|
|
|
const data = await apiCache.fetch(
|
|
|
|
|
'/renderers',
|
|
|
|
|
undefined,
|
|
|
|
|
() => api.getRenderers(),
|
|
|
|
|
{ force, ttl: RENDERERS_CACHE_MS }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
renderersCache.value = new Map(
|
|
|
|
|
data.map((renderer) => [renderer.id, renderer]),
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
lastError = err instanceof Error ? err : new Error("Erreur fetch renderers");
|
|
|
|
|
console.error("[useRenderers] Erreur fetch (attempt " + (attempt + 1) + "):", lastError);
|
|
|
|
|
if (attempt < retries) {
|
|
|
|
|
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false;
|
|
|
|
|
}
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2026-04-06 00:08:50 +02:00
|
|
|
|
|
|
|
|
error.value = lastError?.message ?? "Erreur fetch renderers";
|
2026-04-06 08:47:22 +02:00
|
|
|
|
|
|
|
|
// Notifier l'utilisateur en cas d'erreur finale
|
2026-04-09 18:02:30 +02:00
|
|
|
uiStore.notifyError("Impossible de rafraîchir la liste des lecteurs audio");
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2026-01-09 14:30:53 +01:00
|
|
|
async function fetchRendererSnapshot(
|
|
|
|
|
rendererId: string,
|
|
|
|
|
opts?: { force?: boolean },
|
|
|
|
|
) {
|
2026-04-03 22:26:54 +02:00
|
|
|
ensureSSEInitialized();
|
2026-01-09 14:30:53 +01:00
|
|
|
const force = opts?.force ?? false;
|
2026-04-06 09:50:54 +02:00
|
|
|
const hasSnapshot = snapshots.has(rendererId);
|
2025-12-06 00:38:06 +01:00
|
|
|
|
|
|
|
|
if (!force && hasSnapshot) {
|
2026-04-06 09:50:54 +02:00
|
|
|
const lastSnapshot = lastSnapshotAt.get(rendererId) ?? 0;
|
|
|
|
|
const lastEvent = lastEventAt.get(rendererId) ?? 0;
|
2025-12-06 00:38:06 +01:00
|
|
|
if (lastEvent <= lastSnapshot) {
|
2026-01-09 14:30:53 +01:00
|
|
|
return;
|
2025-12-05 23:37:21 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 08:06:21 +02:00
|
|
|
// Éviter les requêtes multiples simultanées pour le même renderer
|
2026-04-06 09:50:54 +02:00
|
|
|
if (loadingIds.has(rendererId)) {
|
2026-01-09 14:30:53 +01:00
|
|
|
return;
|
2025-12-05 23:37:21 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 09:50:54 +02:00
|
|
|
loadingIds.add(rendererId);
|
|
|
|
|
|
2026-04-06 08:47:22 +02:00
|
|
|
|
|
|
|
|
// Lazy load UI store pour les notifications
|
|
|
|
|
const uiStore = useUIStore();
|
|
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
try {
|
2026-01-09 14:30:53 +01:00
|
|
|
const snapshot = await api.getRendererFullSnapshot(rendererId);
|
2026-04-06 09:50:54 +02:00
|
|
|
snapshots.set(rendererId, snapshot);
|
|
|
|
|
lastSnapshotAt.set(rendererId, Date.now());
|
2026-06-07 14:56:44 +02:00
|
|
|
consecutiveSnapshotErrors.set(rendererId, 0);
|
|
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
} catch (err) {
|
2026-06-07 14:56:44 +02:00
|
|
|
const count = (consecutiveSnapshotErrors.get(rendererId) ?? 0) + 1;
|
|
|
|
|
consecutiveSnapshotErrors.set(rendererId, count);
|
|
|
|
|
console.error(`[useRenderers] Erreur snapshot ${rendererId} (${count}/${SNAPSHOT_ERROR_THRESHOLD}):`, err);
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
2025-12-06 00:38:06 +01:00
|
|
|
} finally {
|
2026-04-06 09:50:54 +02:00
|
|
|
loadingIds.delete(rendererId);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2026-04-03 22:32:02 +02:00
|
|
|
/**
|
|
|
|
|
* Fetch les snapshots de plusieurs renderers en parallèle controlée.
|
|
|
|
|
* - Limite le nombre de requêtes simultanées (concurrency)
|
|
|
|
|
* - Ajoute un délai entre chaque batch pour ne pas saturer le réseau
|
|
|
|
|
* - Continue même si certaines requêtes échouent
|
|
|
|
|
*/
|
|
|
|
|
async function fetchBatchSnapshots(
|
|
|
|
|
rendererIds: string[],
|
|
|
|
|
options: {
|
|
|
|
|
concurrency?: number; // Nombre max de requêtes parallèles (défaut: 3)
|
|
|
|
|
batchDelay?: number; // Délai entre les batches en ms (défaut: 100ms)
|
|
|
|
|
force?: boolean; // Forcer le refetch même en cache
|
|
|
|
|
} = {}
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const { concurrency = 3, batchDelay = 100, force = false } = options;
|
|
|
|
|
|
|
|
|
|
// Filtrer les rendererIds valides
|
|
|
|
|
const validIds = rendererIds.filter(id => id && typeof id === 'string');
|
|
|
|
|
|
|
|
|
|
if (validIds.length === 0) return;
|
|
|
|
|
|
|
|
|
|
// Fonction pour traiter un batch
|
|
|
|
|
const processBatch = async (batch: string[]): Promise<void> => {
|
|
|
|
|
await Promise.allSettled(
|
|
|
|
|
batch.map(id => fetchRendererSnapshot(id, { force }))
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Exécuter par batches avec controlled concurrency
|
|
|
|
|
for (let i = 0; i < validIds.length; i += concurrency) {
|
|
|
|
|
const batch = validIds.slice(i, i + concurrency);
|
|
|
|
|
await processBatch(batch);
|
|
|
|
|
|
|
|
|
|
// Délai entre les batches (sauf pour le dernier)
|
|
|
|
|
if (i + concurrency < validIds.length) {
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, batchDelay));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
// Transport controls
|
|
|
|
|
async function play(id: string) {
|
2026-01-09 14:30:53 +01:00
|
|
|
await api.play(id);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
async function resumeOrPlayFromQueue(id: string) {
|
2026-04-06 09:50:54 +02:00
|
|
|
const snapshot = snapshots.get(id);
|
2025-12-06 00:38:06 +01:00
|
|
|
if (!snapshot) {
|
2026-01-09 14:30:53 +01:00
|
|
|
throw new Error(`Renderer ${id} non trouvé`);
|
2025-12-05 23:37:21 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-09 14:30:53 +01:00
|
|
|
const state = snapshot.state;
|
|
|
|
|
if (state.transport_state === "PAUSED") {
|
|
|
|
|
return play(id);
|
2025-12-05 23:37:21 +01:00
|
|
|
}
|
|
|
|
|
|
2026-06-07 15:10:54 +02:00
|
|
|
// 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) {
|
2026-01-09 14:30:53 +01:00
|
|
|
return api.resume(id);
|
2025-12-05 23:37:21 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-09 14:30:53 +01:00
|
|
|
throw new Error(
|
|
|
|
|
"La file d'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.",
|
|
|
|
|
);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
async function pause(id: string) {
|
2026-01-09 14:30:53 +01:00
|
|
|
await api.pause(id);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
async function stop(id: string) {
|
2026-01-09 14:30:53 +01:00
|
|
|
await api.stop(id);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
async function next(id: string) {
|
2026-01-09 14:30:53 +01:00
|
|
|
await api.next(id);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
// Volume controls
|
|
|
|
|
async function setVolume(id: string, volume: number) {
|
2026-01-09 14:30:53 +01:00
|
|
|
await api.setVolume(id, volume);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
async function volumeUp(id: string) {
|
2026-01-09 14:30:53 +01:00
|
|
|
await api.volumeUp(id);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
async function volumeDown(id: string) {
|
2026-01-09 14:30:53 +01:00
|
|
|
await api.volumeDown(id);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
async function toggleMute(id: string) {
|
2026-01-09 14:30:53 +01:00
|
|
|
await api.toggleMute(id);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
// Playlist binding
|
|
|
|
|
async function attachPlaylist(
|
|
|
|
|
rendererId: string,
|
|
|
|
|
serverId: string,
|
|
|
|
|
containerId: string,
|
|
|
|
|
options?: { autoPlay?: boolean },
|
|
|
|
|
) {
|
2026-01-09 14:30:53 +01:00
|
|
|
await api.attachPlaylist(
|
|
|
|
|
rendererId,
|
|
|
|
|
serverId,
|
|
|
|
|
containerId,
|
|
|
|
|
options?.autoPlay ?? false,
|
|
|
|
|
);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
async function detachPlaylist(rendererId: string) {
|
2026-01-09 14:30:53 +01:00
|
|
|
await api.detachPlaylist(rendererId);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
async function attachAndPlayPlaylist(
|
|
|
|
|
rendererId: string,
|
|
|
|
|
serverId: string,
|
|
|
|
|
containerId: string,
|
|
|
|
|
) {
|
2026-01-09 14:30:53 +01:00
|
|
|
await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true });
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
// Queue content
|
2026-01-09 14:30:53 +01:00
|
|
|
async function playContent(
|
|
|
|
|
rendererId: string,
|
|
|
|
|
serverId: string,
|
|
|
|
|
objectId: string,
|
|
|
|
|
) {
|
|
|
|
|
await api.playContent(rendererId, serverId, objectId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function addToQueue(
|
|
|
|
|
rendererId: string,
|
|
|
|
|
serverId: string,
|
|
|
|
|
objectId: string,
|
|
|
|
|
) {
|
|
|
|
|
await api.addToQueue(rendererId, serverId, objectId);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2026-01-09 14:30:53 +01:00
|
|
|
async function addAfterCurrent(
|
|
|
|
|
rendererId: string,
|
|
|
|
|
serverId: string,
|
|
|
|
|
objectId: string,
|
|
|
|
|
) {
|
|
|
|
|
await api.addAfterCurrent(rendererId, serverId, objectId);
|
2025-12-06 00:38:06 +01:00
|
|
|
}
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
export function useRenderers() {
|
2026-04-03 22:26:54 +02:00
|
|
|
ensureSSEInitialized();
|
2025-12-05 23:37:21 +01:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
loading,
|
|
|
|
|
error,
|
2025-12-06 00:38:06 +01:00
|
|
|
// Collections
|
2025-12-05 23:37:21 +01:00
|
|
|
allRenderers,
|
|
|
|
|
onlineRenderers,
|
|
|
|
|
playingRenderers,
|
2025-12-06 00:38:06 +01:00
|
|
|
// Accessors
|
2025-12-05 23:37:21 +01:00
|
|
|
getRendererById,
|
2025-12-06 00:38:06 +01:00
|
|
|
getSnapshotById,
|
2025-12-05 23:37:21 +01:00
|
|
|
getStateById,
|
|
|
|
|
getQueueById,
|
|
|
|
|
getBindingById,
|
2025-12-06 00:38:06 +01:00
|
|
|
isSnapshotLoading,
|
2026-04-03 20:45:35 +02:00
|
|
|
isQueueRefreshing,
|
2025-12-06 00:38:06 +01:00
|
|
|
selectRenderer,
|
|
|
|
|
// Fetchers
|
2025-12-05 23:37:21 +01:00
|
|
|
fetchRenderers,
|
2025-12-06 00:38:06 +01:00
|
|
|
fetchRendererSnapshot,
|
2026-04-03 22:32:02 +02:00
|
|
|
fetchBatchSnapshots,
|
2025-12-05 23:37:21 +01:00
|
|
|
// Transport controls
|
|
|
|
|
play,
|
|
|
|
|
resumeOrPlayFromQueue,
|
|
|
|
|
pause,
|
|
|
|
|
stop,
|
|
|
|
|
next,
|
|
|
|
|
// Volume controls
|
|
|
|
|
setVolume,
|
|
|
|
|
volumeUp,
|
|
|
|
|
volumeDown,
|
|
|
|
|
toggleMute,
|
|
|
|
|
// Playlist binding
|
|
|
|
|
attachPlaylist,
|
|
|
|
|
detachPlaylist,
|
|
|
|
|
attachAndPlayPlaylist,
|
|
|
|
|
// Queue content
|
|
|
|
|
playContent,
|
2025-12-06 00:38:06 +01:00
|
|
|
addToQueue,
|
2026-01-09 14:30:53 +01:00
|
|
|
addAfterCurrent,
|
2026-04-06 08:47:22 +02:00
|
|
|
// SSE
|
|
|
|
|
resetSSE,
|
2026-01-09 14:30:53 +01:00
|
|
|
};
|
2025-12-05 23:37:21 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useRenderer(rendererId: Ref<string>) {
|
2026-04-03 22:26:54 +02:00
|
|
|
ensureSSEInitialized();
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2026-04-03 22:43:23 +02:00
|
|
|
// Delegates to useRenderers functions - no duplication
|
|
|
|
|
const renderer = computed(() => getRendererById(rendererId.value));
|
|
|
|
|
const snapshot = computed(() => getSnapshotById(rendererId.value));
|
|
|
|
|
const state = computed(() => getStateById(rendererId.value));
|
|
|
|
|
const queue = computed(() => getQueueById(rendererId.value));
|
|
|
|
|
const binding = computed(() => getBindingById(rendererId.value));
|
2026-01-31 11:44:23 +01:00
|
|
|
const isStream = computed(() => snapshot.value?.is_stream ?? false);
|
2026-04-03 22:43:23 +02:00
|
|
|
const queueRefreshing = computed(() => isQueueRefreshing(rendererId.value));
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2026-04-06 08:06:21 +02:00
|
|
|
// Debounce pour éviter les refreshs multiples trop fréquents
|
|
|
|
|
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
const REFRESH_DEBOUNCE_MS = 500;
|
|
|
|
|
|
2026-04-06 08:47:22 +02:00
|
|
|
// Nettoyer le timer debounce si le composant est démonté
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
if (refreshDebounceTimer !== null) {
|
|
|
|
|
clearTimeout(refreshDebounceTimer);
|
|
|
|
|
refreshDebounceTimer = null;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-06 00:38:06 +01:00
|
|
|
async function refresh(force = true) {
|
2026-04-06 08:06:21 +02:00
|
|
|
const currentRendererId = rendererId.value;
|
|
|
|
|
|
|
|
|
|
// Debounce: ignorer si un refresh est en cours pour ce renderer
|
|
|
|
|
if (refreshDebounceTimer !== null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
refreshDebounceTimer = setTimeout(() => {
|
|
|
|
|
refreshDebounceTimer = null;
|
|
|
|
|
}, REFRESH_DEBOUNCE_MS);
|
|
|
|
|
|
2025-12-05 23:37:21 +01:00
|
|
|
await Promise.all([
|
2025-12-06 00:38:06 +01:00
|
|
|
fetchRenderers(force),
|
2026-04-06 08:06:21 +02:00
|
|
|
fetchRendererSnapshot(currentRendererId, { force: true }),
|
2026-01-09 14:30:53 +01:00
|
|
|
]);
|
2025-12-05 23:37:21 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
renderer,
|
2025-12-06 00:38:06 +01:00
|
|
|
snapshot,
|
2025-12-05 23:37:21 +01:00
|
|
|
state,
|
|
|
|
|
queue,
|
|
|
|
|
binding,
|
2026-01-31 11:44:23 +01:00
|
|
|
isStream,
|
2026-04-03 20:45:35 +02:00
|
|
|
queueRefreshing,
|
2025-12-06 00:38:06 +01:00
|
|
|
refresh,
|
2026-01-09 14:30:53 +01:00
|
|
|
};
|
2025-12-05 23:37:21 +01:00
|
|
|
}
|