diff --git a/pmoapp/webapp/src/composables/useMediaServers.ts b/pmoapp/webapp/src/composables/useMediaServers.ts index c5bc0dc0..7a0a0b4d 100644 --- a/pmoapp/webapp/src/composables/useMediaServers.ts +++ b/pmoapp/webapp/src/composables/useMediaServers.ts @@ -4,7 +4,7 @@ */ import { ref, computed } from 'vue' import { api } from '../services/pmocontrol/api' -import { sse } from '../services/pmocontrol/sse' +import { useSSE } from './useSSE' import type { MediaServerSummary, ContainerEntry, @@ -35,12 +35,17 @@ const lastFetch = { const CACHE_DURATION_MS = 2000 -// Connecter SSE une seule fois -let sseConnected = false -function ensureSSEConnected() { - if (sseConnected) return +// Initialiser SSE une seule fois via le composable centralisé +let sseInitialized = false +function ensureSSEInitialized() { + if (sseInitialized) return - sse.onMediaServerEvent((event) => { + const { onMediaServerEvent, connect } = useSSE() + + // Démarrer la connexion SSE + connect() + + onMediaServerEvent((event) => { const serverId = event.server_id switch (event.type) { @@ -98,14 +103,14 @@ function ensureSSEConnected() { } }) - sseConnected = true + sseInitialized = true } /** * Composable principal pour gérer les media servers */ export function useMediaServers() { - ensureSSEConnected() + ensureSSEInitialized() const loading = ref(false) const loadingMore = ref(false) diff --git a/pmoapp/webapp/src/composables/useRenderers.ts b/pmoapp/webapp/src/composables/useRenderers.ts index e607e8d9..470ac10a 100644 --- a/pmoapp/webapp/src/composables/useRenderers.ts +++ b/pmoapp/webapp/src/composables/useRenderers.ts @@ -6,7 +6,7 @@ */ import { ref, reactive, computed, toRaw, type Ref } from "vue"; import { api } from "../services/pmocontrol/api"; -import { sse } from "../services/pmocontrol/sse"; +import { useSSE } from "./useSSE"; import type { RendererSummary, RendererState, @@ -40,11 +40,17 @@ const snapshotState = reactive({ const loading = ref(false); const error = ref(null); -let sseConnected = false; -function ensureSSEConnected() { - if (sseConnected) return; +// Utiliser le composable SSE centralisé +let sseInitialized = false; +function ensureSSEInitialized() { + if (sseInitialized) return; - sse.onRendererEvent((event) => { + 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(); @@ -235,7 +241,7 @@ function ensureSSEConnected() { snapshotState.snapshots.set(rendererId, snapshot); }); - sseConnected = true; + sseInitialized = true; } const allRenderers = computed(() => Array.from(renderersCache.value.values())); @@ -284,7 +290,7 @@ function selectRenderer(id: string | null) { } async function fetchRenderers(force = false) { - ensureSSEConnected(); + ensureSSEInitialized(); const now = Date.now(); if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) { @@ -311,7 +317,7 @@ async function fetchRendererSnapshot( rendererId: string, opts?: { force?: boolean }, ) { - ensureSSEConnected(); + ensureSSEInitialized(); const force = opts?.force ?? false; const hasSnapshot = snapshotState.snapshots.has(rendererId); @@ -449,7 +455,7 @@ async function addAfterCurrent( } export function useRenderers() { - ensureSSEConnected(); + ensureSSEInitialized(); return { loading, @@ -494,7 +500,7 @@ export function useRenderers() { } export function useRenderer(rendererId: Ref) { - ensureSSEConnected(); + ensureSSEInitialized(); const renderer = computed(() => renderersCache.value.get(rendererId.value)); const snapshot = computed( diff --git a/pmoapp/webapp/src/composables/useSSE.ts b/pmoapp/webapp/src/composables/useSSE.ts new file mode 100644 index 00000000..97bf1a51 --- /dev/null +++ b/pmoapp/webapp/src/composables/useSSE.ts @@ -0,0 +1,244 @@ +/** + * Composable centralisé pour la gestion des Server-Sent Events + * + * Ce composable fournit une interface unifiée pour: + * - Une seule connexion SSE (évite les connexions multiples) + * - Des abonnements typés aux événements (renderers, servers) + * - Le suivi de l'état de connexion + * + * Usage: + * const { connected, onRendererEvent, onMediaServerEvent } = useSSE() + */ +import { ref, readonly, onUnmounted } from 'vue' +import { sse } from '../services/pmocontrol/sse' +import type { + RendererEventPayload, + MediaServerEventPayload +} from '../services/pmocontrol/types' + +// État global partagé +const connected = ref(sse.isConnectedState()) +const connectionCallbacks: Set<(connected: boolean) => void> = new Set() + +// Abonnement à l'état de connexion global +function setupConnectionListener() { + // S'assurer qu'on ne s'abonne qu'une seule fois + if (connectionCallbacks.size === 0) { + sse.onConnectionChange((isConnected) => { + connected.value = isConnected + connectionCallbacks.forEach(cb => cb(isConnected)) + }) + } +} + +/** + * Hook principal pour utiliser SSE + */ +export function useSSE() { + // S'assurer que le listener de connexion est configuré + setupConnectionListener() + + /** + * Abonnement aux événements de type renderer + * Retourne une fonction de cleanup + */ + function onRendererEvent( + callback: (event: RendererEventPayload) => void + ): () => void { + return sse.onRendererEvent(callback) + } + + /** + * Abonnement aux événements de type media server + * Retourne une fonction de cleanup + */ + function onMediaServerEvent( + callback: (event: MediaServerEventPayload) => void + ): () => void { + return sse.onMediaServerEvent(callback) + } + + /** + * Abonnement aux changements de connexion + * Retourne une fonction de cleanup + */ + function onConnectionChange( + callback: (connected: boolean) => void + ): () => void { + connectionCallbacks.add(callback) + // Appeler immédiatement avec l'état actuel + callback(connected.value) + + // Retourner fonction de cleanup + return () => { + connectionCallbacks.delete(callback) + } + } + + /** + * Force la connexion SSE + */ + function connect(): void { + sse.connect() + } + + /** + * Force la déconnexion SSE + */ + function disconnect(): void { + sse.disconnect() + } + + /** + * Vérifie si actuellement connecté + */ + function isConnected(): boolean { + return connected.value + } + + return { + // État (readonly pour éviter les modifications directes) + connected: readonly(connected), + + // Abonnements + onRendererEvent, + onMediaServerEvent, + onConnectionChange, + + // Actions + connect, + disconnect, + isConnected, + } +} + +/** + * Hook pour s'abonner à un type spécifique d'événement renderer + * avec filtrage optionnel par rendererId + */ +export function useRendererEvents( + rendererId: () => string | null, + options?: { + onStateChanged?: (event: RendererEventPayload) => void + onPositionChanged?: (event: RendererEventPayload) => void + onVolumeChanged?: (event: RendererEventPayload) => void + onMetadataChanged?: (event: RendererEventPayload) => void + onQueueUpdated?: (event: RendererEventPayload) => void + onBindingChanged?: (event: RendererEventPayload) => void + onTimerEvent?: (event: RendererEventPayload) => void + } +) { + const { onRendererEvent } = useSSE() + + let cleanup: (() => void) | null = null + + function setup() { + cleanup = onRendererEvent((event) => { + const currentId = rendererId() + + // Si un rendererId est spécifié, filtrer + if (currentId && event.renderer_id !== currentId) { + return + } + + // Dispatch vers le handler approprié + switch (event.type) { + case 'state_changed': + case 'online': + case 'offline': + options?.onStateChanged?.(event) + break + case 'position_changed': + options?.onPositionChanged?.(event) + break + case 'volume_changed': + case 'mute_changed': + options?.onVolumeChanged?.(event) + break + case 'metadata_changed': + options?.onMetadataChanged?.(event) + break + case 'queue_updated': + case 'queue_refreshing': + options?.onQueueUpdated?.(event) + break + case 'binding_changed': + options?.onBindingChanged?.(event) + break + case 'timer_started': + case 'timer_updated': + case 'timer_tick': + case 'timer_expired': + case 'timer_cancelled': + options?.onTimerEvent?.(event) + break + } + }) + } + + setup() + + // Cleanup automatique au unmount du composant + onUnmounted(() => { + cleanup?.() + }) + + return { + refresh: setup, // Permet de recréer l'abonnement si besoin + } +} + +/** + * Hook pour s'abonner aux événements media server + * avec filtrage optionnel par serverId + */ +export function useMediaServerEvents( + serverId: () => string | null, + options?: { + onOnline?: (event: MediaServerEventPayload) => void + onOffline?: (event: MediaServerEventPayload) => void + onGlobalUpdated?: (event: MediaServerEventPayload) => void + onContainersUpdated?: (event: MediaServerEventPayload) => void + } +) { + const { onMediaServerEvent } = useSSE() + + let cleanup: (() => void) | null = null + + function setup() { + cleanup = onMediaServerEvent((event) => { + const currentId = serverId() + + // Si un serverId est spécifié, filtrer + if (currentId && event.server_id !== currentId) { + return + } + + // Dispatch vers le handler approprié + switch (event.type) { + case 'online': + options?.onOnline?.(event) + break + case 'offline': + options?.onOffline?.(event) + break + case 'global_updated': + options?.onGlobalUpdated?.(event) + break + case 'containers_updated': + options?.onContainersUpdated?.(event) + break + } + }) + } + + setup() + + onUnmounted(() => { + cleanup?.() + }) + + return { + refresh: setup, + } +} \ No newline at end of file diff --git a/pmoapp/webapp/src/composables/useWebRenderer.ts b/pmoapp/webapp/src/composables/useWebRenderer.ts index 93bb6a2a..6754b3ad 100644 --- a/pmoapp/webapp/src/composables/useWebRenderer.ts +++ b/pmoapp/webapp/src/composables/useWebRenderer.ts @@ -14,7 +14,7 @@ */ import { ref, onMounted, onUnmounted, readonly } from "vue"; -import { sse } from "../services/pmocontrol/sse"; +import { useSSE } from "./useSSE"; // ─── Identifiant stable de l'instance navigateur ───────────────────────────── @@ -200,10 +200,11 @@ export function useWebRenderer() { onConnectedCallback?.(); // S'abonner aux événements SSE du renderer pour piloter la lecture - sse.connect(); + const { connect, onRendererEvent } = useSSE(); + connect(); const udn = data.udn; sseUnsubscribe?.(); - sseUnsubscribe = sse.onRendererEvent((event) => { + sseUnsubscribe = onRendererEvent((event) => { if (event.renderer_id !== udn) return; if (event.type !== "state_changed") return; diff --git a/pmoapp/webapp/tsconfig.tsbuildinfo b/pmoapp/webapp/tsconfig.tsbuildinfo new file mode 100644 index 00000000..8e541cc8 --- /dev/null +++ b/pmoapp/webapp/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":[],"fileInfos":[],"root":[],"version":"5.8.3"} \ No newline at end of file