/** * 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. */ import { ref, reactive, computed, toRaw, type Ref } from "vue"; import { api } from "../services/pmocontrol/api"; import { useSSE } from "./useSSE"; import { apiCache } from "./apiCache"; import { parseTimeToMs } from "../utils/time"; import type { RendererSummary, RendererState, QueueSnapshot, AttachedPlaylistInfo, FullRendererSnapshot, } from "../services/pmocontrol/types"; interface RendererSnapshotState { snapshots: Map; lastSnapshotAt: Map; lastEventAt: Map; loadingIds: Set; queueRefreshingIds: Set; selectedRendererId: string | null; } const renderersCache = ref>(new Map()); const RENDERERS_CACHE_MS = 2000; const snapshotState = reactive({ snapshots: reactive(new Map()), lastSnapshotAt: reactive(new Map()), lastEventAt: reactive(new Map()), loadingIds: reactive(new Set()), queueRefreshingIds: reactive(new Set()), selectedRendererId: null, }); const loading = ref(false); const error = ref(null); // Utiliser le composable SSE centralisé let sseInitialized = false; function ensureSSEInitialized() { if (sseInitialized) return; const { onRendererEvent, connect } = useSSE(); // Démarrer la connexion SSE connect(); onRendererEvent((event) => { const rendererId = event.renderer_id; const timestamp = Date.parse(event.timestamp ?? "") || Date.now(); // Gérer les événements Online/Offline différemment if (event.type === "online") { // 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, protocol: "upnp", // Valeur par défaut, sera mise à jour par le fetch 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, }; renderersCache.value.set(rendererId, renderer); // Fetch la liste complète pour avoir les bonnes infos void fetchRenderers(true); // Fetch le snapshot complet pour ce renderer void fetchRendererSnapshot(rendererId, { force: true }); return; } if (event.type === "offline") { // Marquer comme offline dans le cache const renderer = renderersCache.value.get(rendererId); if (renderer) { renderer.online = false; renderersCache.value.set(rendererId, renderer); } // Supprimer le snapshot (il n'est plus valide) snapshotState.snapshots.delete(rendererId); snapshotState.lastSnapshotAt.delete(rendererId); snapshotState.lastEventAt.delete(rendererId); return; } // Pour les autres événements, mettre à jour le snapshot local directement snapshotState.lastEventAt.set(rendererId, timestamp); const snapshot = snapshotState.snapshots.get(rendererId); // Si pas de snapshot, on doit fetch if (!snapshot) { void fetchRendererSnapshot(rendererId, { force: true }); return; } // Sinon, mettre à jour le snapshot localement selon le type d'événement switch (event.type) { case "state_changed": snapshot.state.transport_state = event.state as any; break; case "position_changed": // 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) // Convertir rel_time (HH:MM:SS) en millisecondes const positionMs = parseTimeToMs(event.rel_time ?? null); snapshot.state.position_ms = positionMs ?? 0; // Convertir track_duration (HH:MM:SS) en millisecondes const durationMs = parseTimeToMs(event.track_duration ?? null); // Si track_duration est null/undefined (flux continu sans durée), // mettre duration_ms à null pour afficher "--:--" snapshot.state.duration_ms = durationMs; // Important: Trigger reactivity en réassignant l'objet complet avec deep copy // Le shallow copy ne suffit pas car snapshot.state est partagé entre renderers // Il faut copier state aussi pour éviter que les modifications d'un renderer // n'affectent les autres renderers // IMPORTANT: Utiliser toRaw() pour obtenir l'objet brut non-réactif avant de copier // sinon Vue copie les getters réactifs qui continuent à pointer vers l'objet d'origine const rawState = toRaw(snapshot.state); const newState = { ...rawState }; const newSnapshot = { ...snapshot, state: newState, }; snapshotState.snapshots.set(rendererId, newSnapshot); break; case "volume_changed": snapshot.state.volume = event.volume; break; case "mute_changed": snapshot.state.mute = event.mute; 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; // Important: Trigger reactivity en réassignant l'objet complet avec deep copy snapshotState.snapshots.set(rendererId, { ...snapshot, state: { ...snapshot.state }, }); 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; 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; } break; case "stream_state_changed": snapshot.is_stream = event.is_stream; break; 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; } // Trigger reactivity snapshotState.snapshots.set(rendererId, snapshot); }); sseInitialized = true; } const allRenderers = computed(() => Array.from(renderersCache.value.values())); const onlineRenderers = computed(() => allRenderers.value.filter((r) => r.online), ); const allSnapshots = computed(() => Array.from(snapshotState.snapshots.values()), ); const playingRenderers = computed(() => allSnapshots.value .filter((snapshot) => snapshot.state.transport_state === "PLAYING") .map((snapshot) => snapshot.state), ); function getRendererById(id: string) { return renderersCache.value.get(id); } function getSnapshotById(id: string) { return snapshotState.snapshots.get(id) ?? null; } function getStateById(id: string): RendererState | null { return snapshotState.snapshots.get(id)?.state ?? null; } function getQueueById(id: string): QueueSnapshot | null { return snapshotState.snapshots.get(id)?.queue ?? null; } function getBindingById(id: string): AttachedPlaylistInfo | null { return snapshotState.snapshots.get(id)?.binding ?? null; } 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; } async function fetchRenderers(force = false) { ensureSSEInitialized(); 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]), ); } catch (err) { error.value = err instanceof Error ? err.message : "Erreur fetch renderers"; console.error("[useRenderers] Erreur fetch:", err); } finally { loading.value = false; } } async function fetchRendererSnapshot( rendererId: string, opts?: { force?: boolean }, ) { ensureSSEInitialized(); const force = opts?.force ?? false; const hasSnapshot = snapshotState.snapshots.has(rendererId); if (!force && hasSnapshot) { const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0; const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0; if (lastEvent <= lastSnapshot) { return; } } if (snapshotState.loadingIds.has(rendererId)) { return; } snapshotState.loadingIds.add(rendererId); try { const snapshot = await api.getRendererFullSnapshot(rendererId); snapshotState.snapshots.set(rendererId, snapshot); snapshotState.lastSnapshotAt.set(rendererId, Date.now()); } catch (err) { console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err); } finally { snapshotState.loadingIds.delete(rendererId); } } /** * 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 { 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 => { 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)); } } } // Transport controls async function play(id: string) { await api.play(id); } async function resumeOrPlayFromQueue(id: string) { const snapshot = snapshotState.snapshots.get(id); if (!snapshot) { throw new Error(`Renderer ${id} non trouvé`); } const state = snapshot.state; if (state.transport_state === "PAUSED") { return play(id); } if ( ["STOPPED", "NO_MEDIA"].includes(state.transport_state) && snapshot.queue.items.length > 0 ) { return api.resume(id); } throw new Error( "La file d'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.", ); } async function pause(id: string) { await api.pause(id); } async function stop(id: string) { await api.stop(id); } async function next(id: string) { await api.next(id); } // Volume controls async function setVolume(id: string, volume: number) { await api.setVolume(id, volume); } async function volumeUp(id: string) { await api.volumeUp(id); } async function volumeDown(id: string) { await api.volumeDown(id); } async function toggleMute(id: string) { await api.toggleMute(id); } // Playlist binding async function attachPlaylist( rendererId: string, serverId: string, containerId: string, options?: { autoPlay?: boolean }, ) { await api.attachPlaylist( rendererId, serverId, containerId, options?.autoPlay ?? false, ); } async function detachPlaylist(rendererId: string) { await api.detachPlaylist(rendererId); } async function attachAndPlayPlaylist( rendererId: string, serverId: string, containerId: string, ) { await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true }); } // Queue content 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); } async function addAfterCurrent( rendererId: string, serverId: string, objectId: string, ) { await api.addAfterCurrent(rendererId, serverId, objectId); } export function useRenderers() { ensureSSEInitialized(); return { loading, error, // Collections allRenderers, onlineRenderers, playingRenderers, // Accessors getRendererById, getSnapshotById, getStateById, getQueueById, getBindingById, isSnapshotLoading, isQueueRefreshing, selectRenderer, snapshotState, // Fetchers fetchRenderers, fetchRendererSnapshot, fetchBatchSnapshots, // Transport controls play, resumeOrPlayFromQueue, pause, stop, next, // Volume controls setVolume, volumeUp, volumeDown, toggleMute, // Playlist binding attachPlaylist, detachPlaylist, attachAndPlayPlaylist, // Queue content playContent, addToQueue, addAfterCurrent, }; } export function useRenderer(rendererId: Ref) { ensureSSEInitialized(); // 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)); const isStream = computed(() => snapshot.value?.is_stream ?? false); const queueRefreshing = computed(() => isQueueRefreshing(rendererId.value)); async function refresh(force = true) { await Promise.all([ fetchRenderers(force), fetchRendererSnapshot(rendererId.value, { force: true }), ]); } return { renderer, snapshot, state, queue, binding, isStream, queueRefreshing, refresh, }; }