push-pqqsxyupswry #21

Merged
eric merged 76 commits from push-pqqsxyupswry into main 2025-12-06 12:49:47 +01:00
18 changed files with 700 additions and 758 deletions
Showing only changes of commit daef8d6150 - Show all commits

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRenderersStore } from '@/stores/renderers'
import { ref } from 'vue'
import { useRenderers } from '@/composables/useRenderers'
import { Play, ListPlus, ChevronRight } from 'lucide-vue-next'
defineProps<{
@@ -14,12 +14,10 @@ const emit = defineEmits<{
addToQueue: [rendererId: string]
}>()
const renderersStore = useRenderersStore()
const { onlineRenderers } = useRenderers()
const showMenu = ref(false)
const showRendererSubmenu = ref<'play' | 'queue' | null>(null)
const onlineRenderers = computed(() => renderersStore.onlineRenderers)
function handleAction(action: 'play' | 'queue', rendererId: string) {
showMenu.value = false
showRendererSubmenu.value = null

View File

@@ -1,15 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRenderersStore } from '@/stores/renderers'
import { computed, toRef } from 'vue'
import { useRenderer } from '@/composables/useRenderers'
import { Music } from 'lucide-vue-next'
const props = defineProps<{
rendererId: string
}>()
const renderersStore = useRenderersStore()
const state = computed(() => renderersStore.getStateById(props.rendererId))
const { state } = useRenderer(toRef(props, 'rendererId'))
const metadata = computed(() => state.value?.current_track)
// Calcul du pourcentage de progression

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useMediaServersStore } from '@/stores/mediaServers'
import { useRenderersStore } from '@/stores/renderers'
import { useMediaServers } from '@/composables/useMediaServers'
import { useRenderers } from '@/composables/useRenderers'
import { useUIStore } from '@/stores/ui'
import type { BreadcrumbItem } from './Breadcrumb.vue'
import Breadcrumb from './Breadcrumb.vue'
import ContainerItem from './ContainerItem.vue'
import MediaItem from './MediaItem.vue'
@@ -14,8 +13,21 @@ const props = defineProps<{
containerId: string
}>()
const mediaServersStore = useMediaServersStore()
const renderersStore = useRenderersStore()
const {
getBrowseCached,
browseContainer,
currentPath: breadcrumbPath,
loading,
error
} = useMediaServers()
const {
playContent,
addToQueue,
attachAndPlayPlaylist,
attachPlaylist,
fetchQueue
} = useRenderers()
const uiStore = useUIStore()
// Flags pour gérer le rechargement automatique avec debounce et cooldown
@@ -25,7 +37,7 @@ const lastRefreshTime = ref<number>(0)
const REFRESH_COOLDOWN_MS = 5000 // Ne pas recharger plus d'une fois toutes les 5 secondes
const browseData = computed(() =>
mediaServersStore.getBrowseCached(props.serverId, props.containerId)
getBrowseCached(props.serverId, props.containerId)
)
const containers = computed(() =>
@@ -36,17 +48,12 @@ const items = computed(() =>
browseData.value?.entries.filter((e) => !e.is_container) || []
)
const loading = computed(() => mediaServersStore.loading)
const error = computed(() => mediaServersStore.error)
const breadcrumbPath = computed<BreadcrumbItem[]>(() => mediaServersStore.currentPath)
// Charger le container au montage et quand containerId change
watch(
() => props.containerId,
async (newContainerId) => {
if (newContainerId) {
await mediaServersStore.browseContainer(props.serverId, newContainerId)
await browseContainer(props.serverId, newContainerId)
}
},
{ immediate: true }
@@ -85,7 +92,7 @@ watch(
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement après debounce...`
)
isRefreshing.value = true
await mediaServersStore.browseContainer(props.serverId, props.containerId)
await browseContainer(props.serverId, props.containerId, false)
lastRefreshTime.value = Date.now() // Enregistrer le moment du rechargement
isRefreshing.value = false
refreshTimeoutId.value = null
@@ -110,7 +117,7 @@ function handleBrowseContainer(containerId: string) {
// Actions handlers pour les containers (playlists/albums)
async function handlePlayContainer(containerId: string, rendererId: string) {
try {
await renderersStore.attachAndPlayPlaylist(rendererId, props.serverId, containerId)
await attachAndPlayPlaylist(rendererId, props.serverId, containerId)
uiStore.notifySuccess('Lecture de la playlist démarrée !')
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
@@ -121,8 +128,8 @@ async function handlePlayContainer(containerId: string, rendererId: string) {
async function handleQueueContainer(containerId: string, rendererId: string) {
try {
// Attacher la playlist (sans démarrer la lecture)
await renderersStore.attachPlaylist(rendererId, props.serverId, containerId)
await renderersStore.fetchQueue(rendererId)
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'
@@ -133,7 +140,7 @@ async function handleQueueContainer(containerId: string, rendererId: string) {
// Actions handlers pour les items (tracks)
async function handlePlayItem(itemId: string, rendererId: string) {
try {
await renderersStore.playContent(rendererId, props.serverId, itemId)
await playContent(rendererId, props.serverId, itemId)
uiStore.notifySuccess('Lecture démarrée !')
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
@@ -143,7 +150,7 @@ async function handlePlayItem(itemId: string, rendererId: string) {
async function handleQueueItem(itemId: string, rendererId: string) {
try {
await renderersStore.addToQueue(rendererId, props.serverId, itemId)
await addToQueue(rendererId, props.serverId, itemId)
uiStore.notifySuccess('Ajouté à la queue !')
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
@@ -170,7 +177,7 @@ async function handleQueueItem(itemId: string, rendererId: string) {
<!-- Error state -->
<div v-else-if="error" class="browser-error">
<p class="error-message">{{ error }}</p>
<button class="btn btn-secondary" @click="mediaServersStore.browseContainer(serverId, containerId)">
<button class="btn btn-secondary" @click="browseContainer(serverId, containerId, false)">
Réessayer
</button>
</div>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRenderersStore } from '@/stores/renderers'
import { computed, toRef } from 'vue'
import { useRenderer, useRenderers } from '@/composables/useRenderers'
import { useUIStore } from '@/stores/ui'
import { Link, Unlink } from 'lucide-vue-next'
@@ -8,15 +8,15 @@ const props = defineProps<{
rendererId: string
}>()
const renderersStore = useRenderersStore()
const { binding } = useRenderer(toRef(props, 'rendererId'))
const { detachPlaylist } = useRenderers()
const uiStore = useUIStore()
const binding = computed(() => renderersStore.getBindingById(props.rendererId))
const isAttached = computed(() => !!binding.value)
async function handleDetach() {
try {
await renderersStore.detachPlaylist(props.rendererId)
await detachPlaylist(props.rendererId)
uiStore.notifySuccess('Playlist détachée avec succès')
} catch (error) {
uiStore.notifyError(`Impossible de détacher la playlist: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { useRenderersStore } from '@/stores/renderers'
import { computed, ref, watch, nextTick, toRef } from 'vue'
import { useRenderer } from '@/composables/useRenderers'
import QueueItem from './QueueItem.vue'
import { Link } from 'lucide-vue-next'
@@ -8,10 +8,7 @@ const props = defineProps<{
rendererId: string
}>()
const renderersStore = useRenderersStore()
const queue = computed(() => renderersStore.getQueueById(props.rendererId))
const binding = computed(() => renderersStore.getBindingById(props.rendererId))
const { queue, binding } = useRenderer(toRef(props, 'rendererId'))
const isAttached = computed(() => !!binding.value)

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRenderersStore } from '@/stores/renderers'
import { computed, toRef } from 'vue'
import { useRenderer, useRenderers } from '@/composables/useRenderers'
import { useUIStore } from '@/stores/ui'
import { Play, Pause, Square, SkipForward } from 'lucide-vue-next'
@@ -8,20 +8,17 @@ const props = defineProps<{
rendererId: string
}>()
const renderersStore = useRenderersStore()
const { state } = useRenderer(toRef(props, 'rendererId'))
const { resumeOrPlayFromQueue, pause, stop, next } = useRenderers()
const uiStore = useUIStore()
const state = computed(() => renderersStore.getStateById(props.rendererId))
const isPlaying = computed(() => state.value?.transport_state === 'PLAYING')
const isPaused = computed(() => state.value?.transport_state === 'PAUSED')
const isStopped = computed(() => state.value?.transport_state === 'STOPPED' || state.value?.transport_state === 'NO_MEDIA')
async function handlePlay() {
try {
// Utilise resumeOrPlayFromQueue qui choisit intelligemment entre:
// - Resume (si PAUSED)
// - Play from queue (si STOPPED/NO_MEDIA avec queue non vide)
await renderersStore.resumeOrPlayFromQueue(props.rendererId)
await resumeOrPlayFromQueue(props.rendererId)
} catch (error) {
uiStore.notifyError(`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
}
@@ -29,7 +26,7 @@ async function handlePlay() {
async function handlePause() {
try {
await renderersStore.pause(props.rendererId)
await pause(props.rendererId)
} catch (error) {
uiStore.notifyError(`Impossible de mettre en pause: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
}
@@ -37,7 +34,7 @@ async function handlePause() {
async function handleStop() {
try {
await renderersStore.stop(props.rendererId)
await stop(props.rendererId)
} catch (error) {
uiStore.notifyError(`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
}
@@ -45,7 +42,7 @@ async function handleStop() {
async function handleNext() {
try {
await renderersStore.next(props.rendererId)
await next(props.rendererId)
} catch (error) {
uiStore.notifyError(`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
}

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRenderersStore } from '@/stores/renderers'
import { ref, watch, toRef } from 'vue'
import { useRenderer, useRenderers } from '@/composables/useRenderers'
import { useUIStore } from '@/stores/ui'
import { Volume2, VolumeX } from 'lucide-vue-next'
@@ -8,10 +8,10 @@ const props = defineProps<{
rendererId: string
}>()
const renderersStore = useRenderersStore()
const { state } = useRenderer(toRef(props, 'rendererId'))
const { setVolume, toggleMute } = useRenderers()
const uiStore = useUIStore()
const state = computed(() => renderersStore.getStateById(props.rendererId))
const localVolume = ref(state.value?.volume ?? 50)
// Synchroniser localVolume avec le state
@@ -34,7 +34,7 @@ function handleVolumeChange(event: Event) {
debounceTimer = window.setTimeout(async () => {
try {
await renderersStore.setVolume(props.rendererId, localVolume.value)
await setVolume(props.rendererId, localVolume.value)
} catch (error) {
uiStore.notifyError(`Impossible de régler le volume: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
}
@@ -44,7 +44,7 @@ function handleVolumeChange(event: Event) {
async function handleToggleMute() {
try {
await renderersStore.toggleMute(props.rendererId)
await toggleMute(props.rendererId)
} catch (error) {
uiStore.notifyError(`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
}

View File

@@ -0,0 +1,183 @@
/**
* Composable pour gérer les media servers
* Architecture simple : l'API est la source de vérité, SSE invalide le cache
*/
import { ref, computed } from 'vue'
import { api } from '../services/pmocontrol/api'
import { sse } from '../services/pmocontrol/sse'
import type {
MediaServerSummary,
BrowseResponse
} from '../services/pmocontrol/types'
export interface BreadcrumbItem {
id: string
title: string
}
// Cache global partagé
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
const browseCache = ref<Map<string, BrowseResponse>>(new Map())
const currentPath = ref<BreadcrumbItem[]>([])
// Timestamps
const lastFetch = {
servers: 0
}
const CACHE_DURATION_MS = 2000
// Connecter SSE une seule fois
let sseConnected = false
function ensureSSEConnected() {
if (sseConnected) return
sse.onMediaServerEvent((event) => {
const serverId = event.server_id
switch (event.type) {
case 'global_updated':
// Invalider tout le cache de ce serveur
console.log(`[useMediaServers] GlobalUpdated pour ${serverId}`)
const keysToDelete: string[] = []
browseCache.value.forEach((_, key) => {
if (key.startsWith(serverId + '/')) {
keysToDelete.push(key)
}
})
keysToDelete.forEach(key => browseCache.value.delete(key))
break
case 'containers_updated':
// Invalider les containers spécifiques
console.log(`[useMediaServers] ContainersUpdated pour ${serverId}:`, event.container_ids)
event.container_ids.forEach(containerId => {
const key = `${serverId}/${containerId}`
browseCache.value.delete(key)
})
break
}
})
sseConnected = true
}
/**
* Composable principal pour gérer les media servers
*/
export function useMediaServers() {
ensureSSEConnected()
const loading = ref(false)
const error = ref<string | null>(null)
// Getters computed
const allServers = computed(() => Array.from(serversCache.value.values()))
const onlineServers = computed(() => allServers.value.filter(s => s.online))
// Fetch servers list
async function fetchServers(force = false) {
const now = Date.now()
if (!force && now - lastFetch.servers < CACHE_DURATION_MS) {
return // Cache encore valide
}
try {
loading.value = true
error.value = null
const data = await api.getServers()
serversCache.value.clear()
data.forEach(s => serversCache.value.set(s.id, s))
lastFetch.servers = now
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur fetch servers'
console.error('[useMediaServers] Erreur fetch:', e)
} finally {
loading.value = false
}
}
// Browse container (avec cache automatique)
async function browseContainer(serverId: string, containerId: string, useCache = true) {
const key = `${serverId}/${containerId}`
// Vérifier le cache
if (useCache && browseCache.value.has(key)) {
return browseCache.value.get(key)!
}
try {
loading.value = true
error.value = null
const data = await api.browseContainer(serverId, containerId)
// Mettre en cache
browseCache.value.set(key, data)
return data
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur browse container'
console.error(`[useMediaServers] Erreur browse ${serverId}/${containerId}:`, e)
throw e
} finally {
loading.value = false
}
}
// Getters
function getServerById(id: string) {
return serversCache.value.get(id)
}
function getBrowseCached(serverId: string, containerId: string) {
const key = `${serverId}/${containerId}`
return browseCache.value.get(key)
}
// Breadcrumb path management
function setPath(path: BreadcrumbItem[]) {
currentPath.value = path
}
function clearPath() {
currentPath.value = []
}
// Invalidation du cache
function invalidateCache(serverId: string, containerId?: string) {
if (containerId) {
// Invalider un container spécifique
const key = `${serverId}/${containerId}`
browseCache.value.delete(key)
} else {
// Invalider tous les containers d'un serveur
const keysToDelete: string[] = []
browseCache.value.forEach((_, key) => {
if (key.startsWith(serverId + '/')) {
keysToDelete.push(key)
}
})
keysToDelete.forEach(key => browseCache.value.delete(key))
}
}
return {
// État
loading,
error,
currentPath,
// Getters
allServers,
onlineServers,
getServerById,
getBrowseCached,
// Actions
fetchServers,
browseContainer,
setPath,
clearPath,
invalidateCache
}
}

View File

@@ -0,0 +1,340 @@
/**
* Composable pour gérer les renderers
* Architecture simple : l'API est la source de vérité, SSE déclenche des re-fetch
*/
import { ref, computed, type Ref } from 'vue'
import { api } from '../services/pmocontrol/api'
import { sse } from '../services/pmocontrol/sse'
import type {
RendererSummary,
RendererState,
QueueSnapshot,
AttachedPlaylistInfo
} 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>()
}
const CACHE_DURATION_MS = 2000 // 2 secondes
// 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
}
})
sseConnected = true
}
/**
* Composable principal pour gérer les renderers
*/
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
allRenderers,
onlineRenderers,
playingRenderers,
getRendererById,
getStateById,
getQueueById,
getBindingById,
// Actions fetch
fetchRenderers,
fetchRendererState,
fetchQueue,
fetchBinding,
// Transport controls
play,
resumeOrPlayFromQueue,
pause,
stop,
next,
// Volume controls
setVolume,
volumeUp,
volumeDown,
toggleMute,
// Playlist binding
attachPlaylist,
detachPlaylist,
attachAndPlayPlaylist,
// Queue content
playContent,
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))
// Auto-refresh au montage
const { fetchRendererState, fetchQueue, fetchBinding } = useRenderers()
async function refresh() {
await Promise.all([
fetchRendererState(rendererId.value, true),
fetchQueue(rendererId.value, true),
fetchBinding(rendererId.value, true)
])
}
return {
renderer,
state,
queue,
binding,
refresh
}
}

View File

@@ -3,14 +3,12 @@ import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
// Stores
import { useRenderersStore } from "./stores/renderers";
import { useMediaServersStore } from "./stores/mediaServers";
import { useUIStore } from "./stores/ui";
// Service SSE
// Service SSE (les composables se connectent automatiquement)
import { sse } from "./services/pmocontrol/sse";
// Store UI (garde UIStore pour les notifications et état UI global)
import { useUIStore } from "./stores/ui";
// Styles
import "./style.css";
import "./assets/styles/variables.css";
@@ -27,35 +25,16 @@ app.use(router);
// Monter l'application
app.mount("#app");
// Après montage, initialiser SSE et connecter aux stores
const renderersStore = useRenderersStore();
const mediaServersStore = useMediaServersStore();
// Après montage, initialiser SSE
const uiStore = useUIStore();
// Connecter SSE aux stores
// Note: Les métadonnées proviennent de l'API (current_track dans RendererState)
// Les événements SSE ne servent qu'à notifier les changements
sse.onRendererEvent((event) => {
renderersStore.updateFromSSE(event);
});
sse.onMediaServerEvent((event) => {
mediaServersStore.updateFromSSE(event);
});
// Les composables se connectent automatiquement à SSE
// Ils gèrent eux-mêmes le re-fetch lors des événements
sse.onConnectionChange((connected) => {
uiStore.setSSEConnected(connected);
if (connected) {
console.log("[App] SSE connecté - Chargement des données initiales");
// Charger les données initiales
renderersStore.fetchRenderers();
mediaServersStore.fetchServers();
// Recharger la queue du renderer sélectionné s'il y en a un
if (uiStore.selectedRendererId) {
console.log(`[App] Rechargement de la queue du renderer ${uiStore.selectedRendererId}`);
renderersStore.fetchQueue(uiStore.selectedRendererId);
}
console.log("[App] SSE connecté");
}
});

View File

@@ -1,144 +0,0 @@
// Store Pinia pour les media servers
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '../services/pmocontrol/api'
import type {
MediaServerSummary,
BrowseResponse,
MediaServerEventPayload
} from '../services/pmocontrol/types'
export interface BreadcrumbItem {
id: string
title: string
}
export const useMediaServersStore = defineStore('mediaServers', () => {
// État
const servers = ref<Map<string, MediaServerSummary>>(new Map())
const browseCache = ref<Map<string, BrowseResponse>>(new Map())
const currentPath = ref<BreadcrumbItem[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// Getters
const onlineServers = computed(() => {
return Array.from(servers.value.values()).filter(s => s.online)
})
const getServerById = (id: string) => {
return servers.value.get(id)
}
const getBrowseCached = (serverId: string, containerId: string) => {
const key = `${serverId}/${containerId}`
return browseCache.value.get(key)
}
// Actions
async function fetchServers() {
try {
loading.value = true
error.value = null
const data = await api.getServers()
// Mettre à jour le Map
servers.value.clear()
data.forEach(s => servers.value.set(s.id, s))
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur fetch servers'
console.error('[Store MediaServers] Erreur fetch:', e)
} finally {
loading.value = false
}
}
async function browseContainer(serverId: string, containerId: string) {
try {
loading.value = true
error.value = null
const data = await api.browseContainer(serverId, containerId)
// Mettre en cache
const key = `${serverId}/${containerId}`
browseCache.value.set(key, data)
return data
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur browse container'
console.error(`[Store MediaServers] Erreur browse ${serverId}/${containerId}:`, e)
throw e
} finally {
loading.value = false
}
}
function setPath(path: BreadcrumbItem[]) {
currentPath.value = path
}
function clearPath() {
currentPath.value = []
}
function invalidateCache(serverId: string, containerId?: string) {
if (containerId) {
// Invalider un container spécifique
const key = `${serverId}/${containerId}`
browseCache.value.delete(key)
} else {
// Invalider tous les containers d'un serveur
const keysToDelete: string[] = []
browseCache.value.forEach((_, key) => {
if (key.startsWith(serverId + '/')) {
keysToDelete.push(key)
}
})
keysToDelete.forEach(key => browseCache.value.delete(key))
}
}
// SSE event handling
function updateFromSSE(event: MediaServerEventPayload) {
const serverId = event.server_id
switch (event.type) {
case 'global_updated': {
// Invalider tout le cache de ce serveur
console.log(`[Store MediaServers] GlobalUpdated pour ${serverId}`)
invalidateCache(serverId)
break
}
case 'containers_updated': {
// Invalider les containers spécifiques
console.log(`[Store MediaServers] ContainersUpdated pour ${serverId}:`, event.container_ids)
event.container_ids.forEach(containerId => {
invalidateCache(serverId, containerId)
})
break
}
}
}
return {
// État
servers,
browseCache,
currentPath,
loading,
error,
// Getters
onlineServers,
getServerById,
getBrowseCached,
// Actions
fetchServers,
browseContainer,
setPath,
clearPath,
invalidateCache,
updateFromSSE,
}
})

View File

@@ -1,62 +0,0 @@
// Store Pinia pour le playback (métadonnées)
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { TrackMetadata, RendererEventPayload } from '../services/pmocontrol/types'
export const usePlaybackStore = defineStore('playback', () => {
// État
// Map de renderer_id → métadonnées de la piste courante
const currentTracks = ref<Map<string, TrackMetadata>>(new Map())
// Getters
const getTrackMetadata = (rendererId: string) => {
return currentTracks.value.get(rendererId)
}
// Actions
function updateMetadata(rendererId: string, metadata: TrackMetadata) {
currentTracks.value.set(rendererId, metadata)
}
function clearMetadata(rendererId: string) {
currentTracks.value.delete(rendererId)
}
// SSE event handling
function updateFromSSE(event: RendererEventPayload) {
const rendererId = event.renderer_id
switch (event.type) {
case 'metadata_changed': {
const metadata: TrackMetadata = {
title: event.title,
artist: event.artist,
album: event.album,
album_art_uri: event.album_art_uri,
duration_ms: null,
}
updateMetadata(rendererId, metadata)
break
}
case 'state_changed': {
// Si stopped, on peut clear les métadonnées
if (event.state === 'STOPPED' || event.state === 'NO_MEDIA') {
clearMetadata(rendererId)
}
break
}
}
}
return {
// État
currentTracks,
// Getters
getTrackMetadata,
// Actions
updateMetadata,
clearMetadata,
updateFromSSE,
}
})

View File

@@ -1,426 +0,0 @@
// Store Pinia pour les renderers
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '../services/pmocontrol/api'
import type {
RendererSummary,
RendererState,
QueueSnapshot,
AttachedPlaylistInfo,
RendererEventPayload
} from '../services/pmocontrol/types'
export const useRenderersStore = defineStore('renderers', () => {
// État
const renderers = ref<Map<string, RendererSummary>>(new Map())
const states = ref<Map<string, RendererState>>(new Map())
const queues = ref<Map<string, QueueSnapshot>>(new Map())
const bindings = ref<Map<string, AttachedPlaylistInfo | null>>(new Map())
const loading = ref(false)
const error = ref<string | null>(null)
// Getters
const onlineRenderers = computed(() => {
return Array.from(renderers.value.values()).filter(r => r.online)
})
const playingRenderers = computed(() => {
return Array.from(states.value.values()).filter(
s => s.transport_state === 'PLAYING'
)
})
const getRendererById = (id: string) => {
return renderers.value.get(id)
}
const getStateById = (id: string) => {
return states.value.get(id)
}
const getQueueById = (id: string) => {
return queues.value.get(id)
}
const getBindingById = (id: string) => {
return bindings.value.get(id)
}
// Actions - Fetch data
async function fetchRenderers() {
try {
loading.value = true
error.value = null
const data = await api.getRenderers()
// Mettre à jour le Map
renderers.value.clear()
data.forEach(r => renderers.value.set(r.id, r))
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur fetch renderers'
console.error('[Store Renderers] Erreur fetch:', e)
} finally {
loading.value = false
}
}
async function fetchRendererState(id: string) {
try {
const state = await api.getRendererState(id)
states.value.set(id, state)
} catch (e) {
console.error(`[Store Renderers] Erreur fetch state ${id}:`, e)
}
}
async function fetchQueue(id: string) {
try {
const queue = await api.getQueue(id)
queues.value.set(id, queue)
} catch (e) {
console.error(`[Store Renderers] Erreur fetch queue ${id}:`, e)
}
}
async function fetchBinding(id: string) {
try {
const binding = await api.getBinding(id)
bindings.value.set(id, binding)
} catch (e) {
console.error(`[Store Renderers] Erreur fetch binding ${id}:`, e)
}
}
// Actions - Transport controls
async function play(id: string) {
try {
await api.play(id)
// L'état sera mis à jour via SSE
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur play'
console.error(`[Store Renderers] Erreur play ${id}:`, e)
throw e
}
}
/**
* Démarre ou reprend la lecture de manière intelligente:
* - Si PAUSED: reprend avec AVTransport.Play (resume)
* - Si STOPPED/NO_MEDIA avec queue non vide: reprend depuis le morceau actuel (via /resume)
* - Sinon: erreur (queue vide)
*/
async function resumeOrPlayFromQueue(id: string) {
const state = getStateById(id)
if (!state) {
throw new Error(`Renderer ${id} non trouvé`)
}
// Cas 1: En pause → reprendre normalement
if (state.transport_state === 'PAUSED') {
return play(id)
}
// Cas 2: Arrêté ou NO_MEDIA avec une queue → reprendre depuis le morceau actuel
if ((state.transport_state === 'STOPPED' || state.transport_state === 'NO_MEDIA') &&
state.queue_len && state.queue_len > 0) {
// Utiliser /resume qui appelle play_current_from_queue côté backend
// Cela reprend la lecture depuis le morceau actuel sans avancer
return resume(id)
}
// Cas 3: Queue vide → erreur explicite
throw new Error('La file d\'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.')
}
async function pause(id: string) {
try {
await api.pause(id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur pause'
console.error(`[Store Renderers] Erreur pause ${id}:`, e)
throw e
}
}
async function stop(id: string) {
try {
await api.stop(id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur stop'
console.error(`[Store Renderers] Erreur stop ${id}:`, e)
throw e
}
}
async function resume(id: string) {
try {
await api.resume(id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur resume'
console.error(`[Store Renderers] Erreur resume ${id}:`, e)
throw e
}
}
async function next(id: string) {
try {
await api.next(id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur next'
console.error(`[Store Renderers] Erreur next ${id}:`, e)
throw e
}
}
// Actions - Volume controls
async function setVolume(id: string, volume: number) {
try {
await api.setVolume(id, volume)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur set volume'
console.error(`[Store Renderers] Erreur set volume ${id}:`, e)
throw e
}
}
async function volumeUp(id: string) {
try {
await api.volumeUp(id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur volume up'
console.error(`[Store Renderers] Erreur volume up ${id}:`, e)
throw e
}
}
async function volumeDown(id: string) {
try {
await api.volumeDown(id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur volume down'
console.error(`[Store Renderers] Erreur volume down ${id}:`, e)
throw e
}
}
async function toggleMute(id: string) {
try {
await api.toggleMute(id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur toggle mute'
console.error(`[Store Renderers] Erreur toggle mute ${id}:`, e)
throw e
}
}
// Actions - Playlist binding
async function attachPlaylist(rendererId: string, serverId: string, containerId: string) {
try {
await api.attachPlaylist(rendererId, serverId, containerId)
// Rafraîchir le binding
await fetchBinding(rendererId)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur attach playlist'
console.error(`[Store Renderers] Erreur attach playlist ${rendererId}:`, e)
throw e
}
}
async function detachPlaylist(rendererId: string) {
try {
await api.detachPlaylist(rendererId)
bindings.value.set(rendererId, null)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur detach playlist'
console.error(`[Store Renderers] Erreur detach playlist ${rendererId}:`, e)
throw e
}
}
/**
* Attache une playlist à un renderer et démarre la lecture
*
* Note: Le backend démarre automatiquement la lecture lors de l'attachement
* via start_queue_playback_if_idle, donc pas besoin d'appeler play() explicitement.
* On rafraîchit seulement le binding et la queue pour l'UI.
*/
async function attachAndPlayPlaylist(rendererId: string, serverId: string, containerId: string) {
try {
// 1. Attacher la playlist au renderer
// Le backend va automatiquement démarrer la lecture via start_queue_playback_if_idle
await api.attachPlaylist(rendererId, serverId, containerId)
// 2. Rafraîchir le binding local
await fetchBinding(rendererId)
// 3. Recharger la queue pour avoir le contenu immédiatement dans l'UI
await fetchQueue(rendererId)
// Note: Pas besoin d'appeler play() ici - le backend le fait automatiquement!
// Appeler play() ici créerait une race condition entre AVTransport.Play
// et le play_next_from_queue du backend.
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur attach and play playlist'
console.error(`[Store Renderers] Erreur attach and play playlist ${rendererId}:`, e)
throw e
}
}
// Actions - Queue content
async function playContent(rendererId: string, serverId: string, objectId: string) {
try {
await api.playContent(rendererId, serverId, objectId)
// L'état et la queue seront mis à jour via SSE
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur play content'
console.error(`[Store Renderers] Erreur play content ${rendererId}:`, e)
throw e
}
}
async function addToQueue(rendererId: string, serverId: string, objectId: string) {
try {
await api.addToQueue(rendererId, serverId, objectId)
// La queue sera mise à jour via SSE
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur add to queue'
console.error(`[Store Renderers] Erreur add to queue ${rendererId}:`, e)
throw e
}
}
// SSE event handling
function updateFromSSE(event: RendererEventPayload) {
const rendererId = event.renderer_id
switch (event.type) {
case 'state_changed': {
const state = states.value.get(rendererId)
if (state) {
state.transport_state = event.state as any
// Effacer les métadonnées si stopped/no_media
if (event.state === 'STOPPED' || event.state === 'NO_MEDIA') {
state.current_track = null
}
}
break
}
case 'position_changed': {
const state = states.value.get(rendererId)
if (state && event.rel_time && event.track_duration) {
// Convertir HH:MM:SS en millisecondes
state.position_ms = parseHMStoMs(event.rel_time)
state.duration_ms = parseHMStoMs(event.track_duration)
}
break
}
case 'volume_changed': {
const state = states.value.get(rendererId)
if (state) {
state.volume = event.volume
}
break
}
case 'mute_changed': {
const state = states.value.get(rendererId)
if (state) {
state.mute = event.mute
}
break
}
case 'queue_updated': {
const state = states.value.get(rendererId)
if (state) {
state.queue_len = event.queue_length
}
// Rafraîchir la queue complète
fetchQueue(rendererId)
break
}
case 'metadata_changed': {
const state = states.value.get(rendererId)
if (state) {
state.current_track = {
title: event.title,
artist: event.artist,
album: event.album,
album_art_uri: event.album_art_uri
}
}
break
}
case 'binding_changed': {
if (event.server_id && event.container_id) {
// Binding créé ou mis à jour
bindings.value.set(rendererId, {
server_id: event.server_id,
container_id: event.container_id,
has_seen_update: false,
})
// Recharger la queue pour refléter le nouveau binding
fetchQueue(rendererId)
} else {
// Binding supprimé
bindings.value.set(rendererId, null)
}
break
}
}
}
// Utilitaire: convertit HH:MM:SS en millisecondes
function parseHMStoMs(hms: string): number {
const parts = hms.split(':').map(Number)
if (parts.length === 3 && parts.every(p => !isNaN(p))) {
const hours = parts[0]!
const minutes = parts[1]!
const seconds = parts[2]!
return (hours * 3600 + minutes * 60 + seconds) * 1000
}
return 0
}
return {
// État
renderers,
states,
queues,
bindings,
loading,
error,
// Getters
onlineRenderers,
playingRenderers,
getRendererById,
getStateById,
getQueueById,
getBindingById,
// Actions
fetchRenderers,
fetchRendererState,
fetchQueue,
fetchBinding,
play,
resumeOrPlayFromQueue,
pause,
stop,
resume,
next,
setVolume,
volumeUp,
volumeDown,
toggleMute,
attachPlaylist,
detachPlaylist,
attachAndPlayPlaylist,
playContent,
addToQueue,
updateFromSSE,
}
})

View File

@@ -1,26 +1,33 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRenderersStore } from '@/stores/renderers'
import { useMediaServersStore } from '@/stores/mediaServers'
import { onMounted } from 'vue'
import { useRenderers } from '@/composables/useRenderers'
import { useMediaServers } from '@/composables/useMediaServers'
import RendererCard from '@/components/pmocontrol/RendererCard.vue'
import MediaServerCard from '@/components/pmocontrol/MediaServerCard.vue'
import { Radio, Server } from 'lucide-vue-next'
const renderersStore = useRenderersStore()
const mediaServersStore = useMediaServersStore()
const {
allRenderers: renderers,
onlineRenderers,
getStateById,
fetchRenderers,
fetchRendererState
} = useRenderers()
const renderers = computed(() => Array.from(renderersStore.renderers.values()))
const onlineRenderers = computed(() => renderersStore.onlineRenderers)
const mediaServers = computed(() => Array.from(mediaServersStore.servers.values()))
const onlineServers = computed(() => mediaServersStore.onlineServers)
const {
allServers: mediaServers,
onlineServers,
fetchServers
} = useMediaServers()
// Charger les données au montage (si pas encore chargées par SSE)
// Charger les données au montage
onMounted(async () => {
if (renderers.value.length === 0) {
await renderersStore.fetchRenderers()
}
if (mediaServers.value.length === 0) {
await mediaServersStore.fetchServers()
await fetchRenderers()
await fetchServers()
// Charger les états de tous les renderers pour afficher les covers
for (const renderer of renderers.value) {
fetchRendererState(renderer.id)
}
})
</script>
@@ -56,13 +63,13 @@ onMounted(async () => {
v-for="renderer in renderers"
:key="renderer.id"
:renderer="renderer"
:state="renderersStore.getStateById(renderer.id) ?? null"
:state="getStateById(renderer.id) ?? null"
/>
</div>
<div v-else class="empty-state">
<p>Aucun renderer découvert</p>
<button class="btn btn-secondary" @click="renderersStore.fetchRenderers()">
<button class="btn btn-secondary" @click="fetchRenderers(true)">
Actualiser
</button>
</div>
@@ -87,7 +94,7 @@ onMounted(async () => {
<div v-else class="empty-state">
<p>Aucun serveur de médias découvert</p>
<button class="btn btn-secondary" @click="mediaServersStore.fetchServers()">
<button class="btn btn-secondary" @click="fetchServers(true)">
Actualiser
</button>
</div>

View File

@@ -1,18 +1,18 @@
<script setup lang="ts">
import { computed, watch, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMediaServersStore } from '@/stores/mediaServers'
import { useMediaServers } from '@/composables/useMediaServers'
import MediaBrowser from '@/components/pmocontrol/MediaBrowser.vue'
import { ArrowLeft, Server } from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const mediaServersStore = useMediaServersStore()
const { getServerById } = useMediaServers()
const serverId = computed(() => route.params.serverId as string)
const containerId = ref(route.query.container as string || '0')
const server = computed(() => mediaServersStore.getServerById(serverId.value))
const server = computed(() => getServerById(serverId.value))
// Watcher sur query.container pour mettre à jour containerId
watch(

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch, toRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRenderersStore } from '@/stores/renderers'
import { useRenderer, useRenderers } from '@/composables/useRenderers'
import { useUIStore } from '@/stores/ui'
import CurrentTrack from '@/components/pmocontrol/CurrentTrack.vue'
import TransportControls from '@/components/pmocontrol/TransportControls.vue'
@@ -20,12 +20,11 @@ import type { OpenHomePlaylistSnapshot } from '@/services/pmocontrol/types'
const route = useRoute()
const router = useRouter()
const renderersStore = useRenderersStore()
const uiStore = useUIStore()
const rendererId = computed(() => route.params.id as string)
const renderer = computed(() => renderersStore.getRendererById(rendererId.value))
const state = computed(() => renderersStore.getStateById(rendererId.value))
const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value))
const { fetchRenderers } = useRenderers()
const openHomeSupported = computed(() => {
const current = renderer.value
if (!current) return false
@@ -48,13 +47,11 @@ onMounted(async () => {
// Indiquer à uiStore quel renderer est sélectionné
uiStore.selectRenderer(rendererId.value)
// Charger toutes les données du renderer
if (!renderer.value) {
await renderersStore.fetchRenderers()
await fetchRenderers()
}
if (!state.value) {
await renderersStore.fetchRendererState(rendererId.value)
}
await renderersStore.fetchQueue(rendererId.value)
await refresh()
})
watch(

View File

@@ -813,6 +813,23 @@ impl ControlPoint {
uri = item.uri.as_str(),
"Queue playback started (current item)"
);
// Sauvegarder les métadonnées dans le snapshot pour que current_track soit disponible
// même si le renderer UPnP ne retourne pas de métadonnées dans GetPositionInfo
let metadata = TrackMetadata {
title: item.title.clone(),
artist: item.artist.clone(),
album: item.album.clone(),
genre: item.genre.clone(),
album_art_uri: item.album_art_uri.clone(),
date: item.date.clone(),
track_number: item.track_number.clone(),
creator: item.creator.clone(),
};
self.runtime.update_snapshot_with(renderer_id, |snapshot| {
snapshot.last_metadata = Some(metadata);
});
self.runtime
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
Ok(())
@@ -899,6 +916,22 @@ impl ControlPoint {
return Err(err);
}
// Sauvegarder les métadonnées dans le snapshot pour que current_track soit disponible
// même si le renderer UPnP ne retourne pas de métadonnées dans GetPositionInfo
let metadata = TrackMetadata {
title: item.title.clone(),
artist: item.artist.clone(),
album: item.album.clone(),
genre: item.genre.clone(),
album_art_uri: item.album_art_uri.clone(),
date: item.date.clone(),
track_number: item.track_number.clone(),
creator: item.creator.clone(),
};
self.runtime.update_snapshot_with(renderer_id, |snapshot| {
snapshot.last_metadata = Some(metadata);
});
self.runtime
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
debug!(

View File

@@ -162,10 +162,11 @@ async fn get_renderer_state(
.map(state_to_string)
.unwrap_or_else(|| "UNKNOWN".to_string());
// Position et durée
let (position_ms, duration_ms) = renderer
.playback_position()
.ok()
// Position, durée et métadonnées depuis GetPositionInfo
let position_info = renderer.playback_position().ok();
let (position_ms, duration_ms) = position_info
.as_ref()
.and_then(|pos| {
let position = parse_hms_to_ms(pos.rel_time.as_deref());
let duration = parse_hms_to_ms(pos.track_duration.as_deref());
@@ -173,6 +174,31 @@ async fn get_renderer_state(
})
.unwrap_or((None, None));
// Extraire les métadonnées depuis le XML track_metadata si disponible
let soap_metadata = position_info.and_then(|pos| {
match &pos.track_metadata {
Some(xml) => {
debug!("GetPositionInfo returned metadata XML: {}", xml);
use crate::openhome_client::parse_track_metadata_from_didl;
match parse_track_metadata_from_didl(xml) {
Some(metadata) => {
debug!("Successfully parsed metadata: title={:?}, artist={:?}",
metadata.title, metadata.artist);
Some(metadata)
}
None => {
debug!("Failed to parse metadata XML");
None
}
}
}
None => {
debug!("GetPositionInfo returned no metadata XML");
None
}
}
});
// Volume et mute
let volume = renderer
.volume()
@@ -180,10 +206,10 @@ async fn get_renderer_state(
.and_then(|v| u8::try_from(v).ok());
let mute = renderer.mute().ok();
(transport_state, position_ms, duration_ms, volume, mute)
(transport_state, position_ms, duration_ms, volume, mute, soap_metadata)
});
let (transport_state, position_ms, duration_ms, volume, mute) =
let (transport_state, position_ms, duration_ms, volume, mute, soap_metadata) =
time::timeout(STATE_QUERY_TIMEOUT, state_task)
.await
.map_err(|_| {
@@ -238,10 +264,22 @@ async fn get_renderer_state(
},
);
// Current track metadata from in-memory snapshot (non-blocking, fast)
let current_track = state
// Current track metadata:
// 1. Essayer d'abord le snapshot en mémoire (rapide, mis à jour par SSE)
// 2. Si None et qu'on a des métadonnées SOAP fraîches, les utiliser
let snapshot_metadata = state
.control_point
.get_current_track_metadata(&rid)
.get_current_track_metadata(&rid);
debug!(
"Renderer {} - snapshot_metadata: {:?}, soap_metadata: {:?}",
renderer_id,
snapshot_metadata.as_ref().map(|m| (&m.title, &m.artist)),
soap_metadata.as_ref().map(|m| (&m.title, &m.artist))
);
let current_track = snapshot_metadata
.or(soap_metadata)
.map(|metadata| CurrentTrackMetadata {
title: metadata.title,
artist: metadata.artist,