Files
pmomusic/pmoapp/webapp/src/composables/useRenderers.ts

555 lines
16 KiB
TypeScript
Raw Normal View History

/**
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.
*/
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,
2025-12-06 00:38:06 +01:00
AttachedPlaylistInfo,
FullRendererSnapshot,
} from "../services/pmocontrol/types";
2025-12-06 00:38:06 +01:00
interface RendererSnapshotState {
snapshots: Map<string, FullRendererSnapshot>;
lastSnapshotAt: Map<string, number>;
lastEventAt: Map<string, number>;
loadingIds: Set<string>;
queueRefreshingIds: Set<string>;
selectedRendererId: string | null;
}
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
const RENDERERS_CACHE_MS = 2000;
2025-12-06 00:38:06 +01:00
const snapshotState = reactive<RendererSnapshotState>({
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
lastSnapshotAt: reactive(new Map<string, number>()),
lastEventAt: reactive(new Map<string, number>()),
loadingIds: reactive(new Set<string>()),
queueRefreshingIds: reactive(new Set<string>()),
2025-12-06 00:38:06 +01:00
selectedRendererId: null,
});
2025-12-06 00:38:06 +01:00
const loading = ref(false);
const error = ref<string | null>(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()),
);
2025-12-06 00:38:06 +01:00
const playingRenderers = computed(() =>
allSnapshots.value
.filter((snapshot) => snapshot.state.transport_state === "PLAYING")
2025-12-06 00:38:06 +01:00
.map((snapshot) => snapshot.state),
);
2025-12-06 00:38:06 +01:00
function getRendererById(id: string) {
return renderersCache.value.get(id);
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
function getSnapshotById(id: string) {
return snapshotState.snapshots.get(id) ?? null;
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
function getStateById(id: string): RendererState | null {
return snapshotState.snapshots.get(id)?.state ?? null;
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
function getQueueById(id: string): QueueSnapshot | null {
return snapshotState.snapshots.get(id)?.queue ?? null;
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
function getBindingById(id: string): AttachedPlaylistInfo | null {
return snapshotState.snapshots.get(id)?.binding ?? null;
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
function isSnapshotLoading(id: string) {
return snapshotState.loadingIds.has(id);
2025-12-06 00:38:06 +01:00
}
function isQueueRefreshing(id: string) {
return snapshotState.queueRefreshingIds.has(id);
}
2025-12-06 00:38:06 +01:00
function selectRenderer(id: string | null) {
snapshotState.selectedRendererId = id;
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
async function fetchRenderers(force = false) {
ensureSSEInitialized();
2025-12-06 00:38:06 +01:00
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]),
);
2025-12-06 00:38:06 +01:00
} catch (err) {
error.value = err instanceof Error ? err.message : "Erreur fetch renderers";
console.error("[useRenderers] Erreur fetch:", err);
2025-12-06 00:38:06 +01:00
} finally {
loading.value = false;
2025-12-06 00:38:06 +01:00
}
}
async function fetchRendererSnapshot(
rendererId: string,
opts?: { force?: boolean },
) {
ensureSSEInitialized();
const force = opts?.force ?? false;
const hasSnapshot = snapshotState.snapshots.has(rendererId);
2025-12-06 00:38:06 +01:00
if (!force && hasSnapshot) {
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0;
const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0;
2025-12-06 00:38:06 +01:00
if (lastEvent <= lastSnapshot) {
return;
}
}
2025-12-06 00:38:06 +01:00
if (snapshotState.loadingIds.has(rendererId)) {
return;
}
snapshotState.loadingIds.add(rendererId);
2025-12-06 00:38:06 +01:00
try {
const snapshot = await api.getRendererFullSnapshot(rendererId);
snapshotState.snapshots.set(rendererId, snapshot);
snapshotState.lastSnapshotAt.set(rendererId, Date.now());
2025-12-06 00:38:06 +01:00
} catch (err) {
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
2025-12-06 00:38:06 +01:00
} finally {
snapshotState.loadingIds.delete(rendererId);
2025-12-06 00:38:06 +01: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) {
await api.play(id);
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
async function resumeOrPlayFromQueue(id: string) {
const snapshot = snapshotState.snapshots.get(id);
2025-12-06 00:38:06 +01:00
if (!snapshot) {
throw new Error(`Renderer ${id} non trouvé`);
}
const state = snapshot.state;
if (state.transport_state === "PAUSED") {
return play(id);
}
2025-12-06 00:38:06 +01:00
if (
["STOPPED", "NO_MEDIA"].includes(state.transport_state) &&
2025-12-06 00:38:06 +01:00
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.",
);
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
async function pause(id: string) {
await api.pause(id);
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
async function stop(id: string) {
await api.stop(id);
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
async function next(id: string) {
await api.next(id);
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
// Volume controls
async function setVolume(id: string, volume: number) {
await api.setVolume(id, volume);
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
async function volumeUp(id: string) {
await api.volumeUp(id);
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
async function volumeDown(id: string) {
await api.volumeDown(id);
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
async function toggleMute(id: string) {
await api.toggleMute(id);
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
// Playlist binding
async function attachPlaylist(
rendererId: string,
serverId: string,
containerId: string,
options?: { autoPlay?: boolean },
) {
await api.attachPlaylist(
rendererId,
serverId,
containerId,
options?.autoPlay ?? false,
);
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
async function detachPlaylist(rendererId: string) {
await api.detachPlaylist(rendererId);
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
async function attachAndPlayPlaylist(
rendererId: string,
serverId: string,
containerId: string,
) {
await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true });
2025-12-06 00:38:06 +01:00
}
2025-12-06 00:38:06 +01:00
// 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);
2025-12-06 00:38:06 +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-06 00:38:06 +01:00
export function useRenderers() {
ensureSSEInitialized();
return {
loading,
error,
2025-12-06 00:38:06 +01:00
// Collections
allRenderers,
onlineRenderers,
playingRenderers,
2025-12-06 00:38:06 +01:00
// Accessors
getRendererById,
2025-12-06 00:38:06 +01:00
getSnapshotById,
getStateById,
getQueueById,
getBindingById,
2025-12-06 00:38:06 +01:00
isSnapshotLoading,
isQueueRefreshing,
2025-12-06 00:38:06 +01:00
selectRenderer,
snapshotState,
// Fetchers
fetchRenderers,
2025-12-06 00:38:06 +01:00
fetchRendererSnapshot,
fetchBatchSnapshots,
// 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,
addAfterCurrent,
};
}
export function useRenderer(rendererId: Ref<string>) {
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));
2025-12-06 00:38:06 +01:00
async function refresh(force = true) {
await Promise.all([
2025-12-06 00:38:06 +01:00
fetchRenderers(force),
fetchRendererSnapshot(rendererId.value, { force: true }),
]);
}
return {
renderer,
2025-12-06 00:38:06 +01:00
snapshot,
state,
queue,
binding,
isStream,
queueRefreshing,
2025-12-06 00:38:06 +01:00
refresh,
};
}