Des debug après la simplification

This commit is contained in:
2025-12-06 00:38:06 +01:00
parent daef8d6150
commit 380e105873
16 changed files with 800 additions and 668 deletions

View File

@@ -26,7 +26,6 @@ const {
addToQueue,
attachAndPlayPlaylist,
attachPlaylist,
fetchQueue
} = useRenderers()
const uiStore = useUIStore()
@@ -127,9 +126,7 @@ async function handlePlayContainer(containerId: string, rendererId: string) {
async function handleQueueContainer(containerId: string, rendererId: string) {
try {
// Attacher la playlist (sans démarrer la lecture)
await attachPlaylist(rendererId, props.serverId, containerId)
await fetchQueue(rendererId, true)
uiStore.notifySuccess('Playlist attachée à la queue !')
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'

View File

@@ -1,292 +1,256 @@
/**
* Composable pour gérer les renderers
* Architecture simple : l'API est la source de vérité, SSE déclenche des re-fetch
* 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, computed, type Ref } from 'vue'
import { ref, reactive, computed, type Ref } from 'vue'
import { api } from '../services/pmocontrol/api'
import { sse } from '../services/pmocontrol/sse'
import type {
RendererSummary,
RendererState,
QueueSnapshot,
AttachedPlaylistInfo
AttachedPlaylistInfo,
FullRendererSnapshot,
} from '../services/pmocontrol/types'
// Cache global partagé entre toutes les instances du composable
const renderersCache = ref<Map<string, RendererSummary>>(new Map())
const statesCache = ref<Map<string, RendererState>>(new Map())
const queuesCache = ref<Map<string, QueueSnapshot>>(new Map())
const bindingsCache = ref<Map<string, AttachedPlaylistInfo | null>>(new Map())
// Timestamps pour éviter les re-fetch trop fréquents
const lastFetch = {
renderers: 0,
states: new Map<string, number>(),
queues: new Map<string, number>(),
bindings: new Map<string, number>()
interface RendererSnapshotState {
snapshots: Map<string, FullRendererSnapshot>
lastSnapshotAt: Map<string, number>
lastEventAt: Map<string, number>
loadingIds: Set<string>
selectedRendererId: string | null
}
const CACHE_DURATION_MS = 2000 // 2 secondes
const renderersCache = ref<Map<string, RendererSummary>>(new Map())
const RENDERERS_CACHE_MS = 2000
const lastRenderersFetch = ref(0)
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>()),
selectedRendererId: null,
})
const loading = ref(false)
const error = ref<string | null>(null)
// Connecter SSE une seule fois au module
let sseConnected = false
function ensureSSEConnected() {
if (sseConnected) return
sse.onRendererEvent((event) => {
const rendererId = event.renderer_id
switch (event.type) {
case 'state_changed':
case 'position_changed':
case 'volume_changed':
case 'mute_changed':
case 'metadata_changed':
// Invalider le cache de l'état et re-fetch
lastFetch.states.delete(rendererId)
api.getRendererState(rendererId).then(state => {
statesCache.value.set(rendererId, state)
})
break
case 'queue_updated':
// Invalider le cache de la queue et re-fetch
lastFetch.queues.delete(rendererId)
api.getQueue(rendererId).then(queue => {
queuesCache.value.set(rendererId, queue)
})
// Mettre à jour queue_len dans l'état
api.getRendererState(rendererId).then(state => {
statesCache.value.set(rendererId, state)
})
break
case 'binding_changed':
// Re-fetch binding et queue
lastFetch.bindings.delete(rendererId)
lastFetch.queues.delete(rendererId)
api.getBinding(rendererId).then(binding => {
bindingsCache.value.set(rendererId, binding)
})
api.getQueue(rendererId).then(queue => {
queuesCache.value.set(rendererId, queue)
})
break
const timestamp = Date.parse(event.timestamp ?? '') || Date.now()
snapshotState.lastEventAt.set(rendererId, timestamp)
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0
if (!snapshotState.snapshots.has(rendererId) || timestamp > lastSnapshot) {
void fetchRendererSnapshot(rendererId, { force: true })
}
})
sseConnected = true
}
/**
* Composable principal pour gérer les renderers
*/
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 selectRenderer(id: string | null) {
snapshotState.selectedRendererId = id
}
async function fetchRenderers(force = false) {
ensureSSEConnected()
const now = Date.now()
if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) {
return
}
try {
loading.value = true
error.value = null
const data = await api.getRenderers()
renderersCache.value = new Map(data.map((renderer) => [renderer.id, renderer]))
lastRenderersFetch.value = now
} 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 }) {
ensureSSEConnected()
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)
}
}
// 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)
}
export function useRenderers() {
ensureSSEConnected()
const loading = ref(false)
const error = ref<string | null>(null)
// Getters computed
const allRenderers = computed(() => Array.from(renderersCache.value.values()))
const onlineRenderers = computed(() => allRenderers.value.filter(r => r.online))
const allStates = computed(() => Array.from(statesCache.value.values()))
const playingRenderers = computed(() =>
allStates.value.filter(s => s.transport_state === 'PLAYING')
)
// Fetch renderers list
async function fetchRenderers(force = false) {
const now = Date.now()
if (!force && now - lastFetch.renderers < CACHE_DURATION_MS) {
return // Cache encore valide
}
try {
loading.value = true
error.value = null
const data = await api.getRenderers()
renderersCache.value.clear()
data.forEach(r => renderersCache.value.set(r.id, r))
lastFetch.renderers = now
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur fetch renderers'
console.error('[useRenderers] Erreur fetch:', e)
} finally {
loading.value = false
}
}
// Fetch renderer state
async function fetchRendererState(id: string, force = false) {
const now = Date.now()
const last = lastFetch.states.get(id) || 0
if (!force && now - last < CACHE_DURATION_MS) {
return // Cache encore valide
}
try {
const state = await api.getRendererState(id)
statesCache.value.set(id, state)
lastFetch.states.set(id, now)
} catch (e) {
console.error(`[useRenderers] Erreur fetch state ${id}:`, e)
}
}
// Fetch queue
async function fetchQueue(id: string, force = false) {
const now = Date.now()
const last = lastFetch.queues.get(id) || 0
if (!force && now - last < CACHE_DURATION_MS) {
return // Cache encore valide
}
try {
const queue = await api.getQueue(id)
queuesCache.value.set(id, queue)
lastFetch.queues.set(id, now)
} catch (e) {
console.error(`[useRenderers] Erreur fetch queue ${id}:`, e)
}
}
// Fetch binding
async function fetchBinding(id: string, force = false) {
const now = Date.now()
const last = lastFetch.bindings.get(id) || 0
if (!force && now - last < CACHE_DURATION_MS) {
return // Cache encore valide
}
try {
const binding = await api.getBinding(id)
bindingsCache.value.set(id, binding)
lastFetch.bindings.set(id, now)
} catch (e) {
console.error(`[useRenderers] Erreur fetch binding ${id}:`, e)
}
}
// Transport controls (pas de cache, juste des commandes)
async function play(id: string) {
await api.play(id)
// SSE mettra à jour l'état automatiquement
}
async function resumeOrPlayFromQueue(id: string) {
const state = statesCache.value.get(id)
if (!state) {
throw new Error(`Renderer ${id} non trouvé`)
}
if (state.transport_state === 'PAUSED') {
return play(id)
}
if ((state.transport_state === 'STOPPED' || state.transport_state === 'NO_MEDIA') &&
state.queue_len && state.queue_len > 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) {
await api.attachPlaylist(rendererId, serverId, containerId)
// Re-fetch binding et queue
await fetchBinding(rendererId, true)
await fetchQueue(rendererId, true)
}
async function detachPlaylist(rendererId: string) {
await api.detachPlaylist(rendererId)
bindingsCache.value.set(rendererId, null)
}
async function attachAndPlayPlaylist(rendererId: string, serverId: string, containerId: string) {
await api.attachPlaylist(rendererId, serverId, containerId)
await fetchBinding(rendererId, true)
await fetchQueue(rendererId, true)
}
// Queue content
async function playContent(rendererId: string, serverId: string, objectId: string) {
await api.playContent(rendererId, serverId, objectId)
// SSE mettra à jour la queue
}
async function addToQueue(rendererId: string, serverId: string, objectId: string) {
await api.addToQueue(rendererId, serverId, objectId)
// SSE mettra à jour la queue
}
// Getters pour un renderer spécifique
function getRendererById(id: string) {
return renderersCache.value.get(id)
}
function getStateById(id: string) {
return statesCache.value.get(id)
}
function getQueueById(id: string) {
return queuesCache.value.get(id)
}
function getBindingById(id: string) {
return bindingsCache.value.get(id)
}
return {
// État
loading,
error,
// Getters
// Collections
allRenderers,
onlineRenderers,
playingRenderers,
// Accessors
getRendererById,
getSnapshotById,
getStateById,
getQueueById,
getBindingById,
// Actions fetch
isSnapshotLoading,
selectRenderer,
snapshotState,
// Fetchers
fetchRenderers,
fetchRendererState,
fetchQueue,
fetchBinding,
fetchRendererSnapshot,
// Transport controls
play,
resumeOrPlayFromQueue,
@@ -304,37 +268,32 @@ export function useRenderers() {
attachAndPlayPlaylist,
// Queue content
playContent,
addToQueue
addToQueue,
}
}
/**
* Composable pour un renderer spécifique (avec auto-refresh)
*/
export function useRenderer(rendererId: Ref<string>) {
ensureSSEConnected()
const renderer = computed(() => renderersCache.value.get(rendererId.value))
const state = computed(() => statesCache.value.get(rendererId.value))
const queue = computed(() => queuesCache.value.get(rendererId.value))
const binding = computed(() => bindingsCache.value.get(rendererId.value))
const snapshot = computed(() => snapshotState.snapshots.get(rendererId.value) ?? null)
const state = computed(() => snapshot.value?.state ?? null)
const queue = computed(() => snapshot.value?.queue ?? null)
const binding = computed(() => snapshot.value?.binding ?? null)
// Auto-refresh au montage
const { fetchRendererState, fetchQueue, fetchBinding } = useRenderers()
async function refresh() {
async function refresh(force = true) {
await Promise.all([
fetchRendererState(rendererId.value, true),
fetchQueue(rendererId.value, true),
fetchBinding(rendererId.value, true)
fetchRenderers(force),
fetchRendererSnapshot(rendererId.value, { force: true }),
])
}
return {
renderer,
snapshot,
state,
queue,
binding,
refresh
refresh,
}
}

View File

@@ -4,6 +4,7 @@
import type {
RendererSummary,
RendererState,
FullRendererSnapshot,
QueueSnapshot,
AttachedPlaylistInfo,
MediaServerSummary,
@@ -68,6 +69,14 @@ class PMOControlAPI {
return this.request<RendererState>(`/renderers/${encodeURIComponent(id)}`)
}
/**
* Récupère le snapshot complet d'un renderer
* GET /api/control/renderers/{id}/full
*/
async getRendererFullSnapshot(id: string): Promise<FullRendererSnapshot> {
return this.request<FullRendererSnapshot>(`/renderers/${encodeURIComponent(id)}/full`)
}
/**
* Récupère la queue d'un renderer (avec current_index)
* GET /api/control/renderers/{id}/queue
@@ -195,9 +204,14 @@ class PMOControlAPI {
async attachPlaylist(
rendererId: string,
serverId: string,
containerId: string
containerId: string,
autoPlay = false
): Promise<SuccessResponse> {
const payload: AttachPlaylistRequest = { server_id: serverId, container_id: containerId }
const payload: AttachPlaylistRequest = {
server_id: serverId,
container_id: containerId,
auto_play: autoPlay,
}
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/binding/attach`, {
method: 'POST',
body: JSON.stringify(payload),

View File

@@ -77,6 +77,12 @@ export interface QueueSnapshot {
current_index: number | null // Index de la piste en cours (null si rien en lecture)
}
export interface FullRendererSnapshot {
state: RendererState
queue: QueueSnapshot
binding: AttachedPlaylistInfo | null
}
// ============================================================================
// OPENHOME PLAYLIST
// ============================================================================
@@ -141,6 +147,7 @@ export interface VolumeSetRequest {
export interface AttachPlaylistRequest {
server_id: string
container_id: string
auto_play?: boolean
}
export interface PlayContentRequest {

View File

@@ -11,7 +11,7 @@ const {
onlineRenderers,
getStateById,
fetchRenderers,
fetchRendererState
fetchRendererSnapshot
} = useRenderers()
const {
@@ -25,9 +25,8 @@ onMounted(async () => {
await fetchRenderers()
await fetchServers()
// Charger les états de tous les renderers pour afficher les covers
for (const renderer of renderers.value) {
fetchRendererState(renderer.id)
fetchRendererSnapshot(renderer.id, { force: true })
}
})
</script>

View File

@@ -24,7 +24,7 @@ const uiStore = useUIStore()
const rendererId = computed(() => route.params.id as string)
const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value))
const { fetchRenderers } = useRenderers()
const { fetchRenderers, selectRenderer: selectRendererSnapshot } = useRenderers()
const openHomeSupported = computed(() => {
const current = renderer.value
if (!current) return false
@@ -44,8 +44,8 @@ const canAddOhTrack = computed(() => newOhUri.value.trim().length > 0)
// Charger les données au montage si nécessaire
onMounted(async () => {
// Indiquer à uiStore quel renderer est sélectionné
uiStore.selectRenderer(rendererId.value)
selectRendererSnapshot(rendererId.value)
// Charger toutes les données du renderer
if (!renderer.value) {
@@ -76,6 +76,7 @@ watch(rendererId, () => {
// Nettoyer la sélection au démontage
onUnmounted(() => {
uiStore.selectRenderer(null)
selectRendererSnapshot(null)
})
function goBack() {