Simplification de la web UI en reposant davantage sur le serveur.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderers } from '@/composables/useRenderers'
|
||||||
import { Play, ListPlus, ChevronRight } from 'lucide-vue-next'
|
import { Play, ListPlus, ChevronRight } from 'lucide-vue-next'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -14,12 +14,10 @@ const emit = defineEmits<{
|
|||||||
addToQueue: [rendererId: string]
|
addToQueue: [rendererId: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const renderersStore = useRenderersStore()
|
const { onlineRenderers } = useRenderers()
|
||||||
const showMenu = ref(false)
|
const showMenu = ref(false)
|
||||||
const showRendererSubmenu = ref<'play' | 'queue' | null>(null)
|
const showRendererSubmenu = ref<'play' | 'queue' | null>(null)
|
||||||
|
|
||||||
const onlineRenderers = computed(() => renderersStore.onlineRenderers)
|
|
||||||
|
|
||||||
function handleAction(action: 'play' | 'queue', rendererId: string) {
|
function handleAction(action: 'play' | 'queue', rendererId: string) {
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
showRendererSubmenu.value = null
|
showRendererSubmenu.value = null
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, toRef } from 'vue'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderer } from '@/composables/useRenderers'
|
||||||
import { Music } from 'lucide-vue-next'
|
import { Music } from 'lucide-vue-next'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
rendererId: string
|
rendererId: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const renderersStore = useRenderersStore()
|
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||||
|
|
||||||
const state = computed(() => renderersStore.getStateById(props.rendererId))
|
|
||||||
const metadata = computed(() => state.value?.current_track)
|
const metadata = computed(() => state.value?.current_track)
|
||||||
|
|
||||||
// Calcul du pourcentage de progression
|
// Calcul du pourcentage de progression
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useMediaServersStore } from '@/stores/mediaServers'
|
import { useMediaServers } from '@/composables/useMediaServers'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderers } from '@/composables/useRenderers'
|
||||||
import { useUIStore } from '@/stores/ui'
|
import { useUIStore } from '@/stores/ui'
|
||||||
import type { BreadcrumbItem } from './Breadcrumb.vue'
|
|
||||||
import Breadcrumb from './Breadcrumb.vue'
|
import Breadcrumb from './Breadcrumb.vue'
|
||||||
import ContainerItem from './ContainerItem.vue'
|
import ContainerItem from './ContainerItem.vue'
|
||||||
import MediaItem from './MediaItem.vue'
|
import MediaItem from './MediaItem.vue'
|
||||||
@@ -14,8 +13,21 @@ const props = defineProps<{
|
|||||||
containerId: string
|
containerId: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const mediaServersStore = useMediaServersStore()
|
const {
|
||||||
const renderersStore = useRenderersStore()
|
getBrowseCached,
|
||||||
|
browseContainer,
|
||||||
|
currentPath: breadcrumbPath,
|
||||||
|
loading,
|
||||||
|
error
|
||||||
|
} = useMediaServers()
|
||||||
|
|
||||||
|
const {
|
||||||
|
playContent,
|
||||||
|
addToQueue,
|
||||||
|
attachAndPlayPlaylist,
|
||||||
|
attachPlaylist,
|
||||||
|
fetchQueue
|
||||||
|
} = useRenderers()
|
||||||
const uiStore = useUIStore()
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
// Flags pour gérer le rechargement automatique avec debounce et cooldown
|
// 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 REFRESH_COOLDOWN_MS = 5000 // Ne pas recharger plus d'une fois toutes les 5 secondes
|
||||||
|
|
||||||
const browseData = computed(() =>
|
const browseData = computed(() =>
|
||||||
mediaServersStore.getBrowseCached(props.serverId, props.containerId)
|
getBrowseCached(props.serverId, props.containerId)
|
||||||
)
|
)
|
||||||
|
|
||||||
const containers = computed(() =>
|
const containers = computed(() =>
|
||||||
@@ -36,17 +48,12 @@ const items = computed(() =>
|
|||||||
browseData.value?.entries.filter((e) => !e.is_container) || []
|
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
|
// Charger le container au montage et quand containerId change
|
||||||
watch(
|
watch(
|
||||||
() => props.containerId,
|
() => props.containerId,
|
||||||
async (newContainerId) => {
|
async (newContainerId) => {
|
||||||
if (newContainerId) {
|
if (newContainerId) {
|
||||||
await mediaServersStore.browseContainer(props.serverId, newContainerId)
|
await browseContainer(props.serverId, newContainerId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -85,7 +92,7 @@ watch(
|
|||||||
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement après debounce...`
|
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement après debounce...`
|
||||||
)
|
)
|
||||||
isRefreshing.value = true
|
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
|
lastRefreshTime.value = Date.now() // Enregistrer le moment du rechargement
|
||||||
isRefreshing.value = false
|
isRefreshing.value = false
|
||||||
refreshTimeoutId.value = null
|
refreshTimeoutId.value = null
|
||||||
@@ -110,7 +117,7 @@ function handleBrowseContainer(containerId: string) {
|
|||||||
// Actions handlers pour les containers (playlists/albums)
|
// Actions handlers pour les containers (playlists/albums)
|
||||||
async function handlePlayContainer(containerId: string, rendererId: string) {
|
async function handlePlayContainer(containerId: string, rendererId: string) {
|
||||||
try {
|
try {
|
||||||
await renderersStore.attachAndPlayPlaylist(rendererId, props.serverId, containerId)
|
await attachAndPlayPlaylist(rendererId, props.serverId, containerId)
|
||||||
uiStore.notifySuccess('Lecture de la playlist démarrée !')
|
uiStore.notifySuccess('Lecture de la playlist démarrée !')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
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) {
|
async function handleQueueContainer(containerId: string, rendererId: string) {
|
||||||
try {
|
try {
|
||||||
// Attacher la playlist (sans démarrer la lecture)
|
// Attacher la playlist (sans démarrer la lecture)
|
||||||
await renderersStore.attachPlaylist(rendererId, props.serverId, containerId)
|
await attachPlaylist(rendererId, props.serverId, containerId)
|
||||||
await renderersStore.fetchQueue(rendererId)
|
await fetchQueue(rendererId, true)
|
||||||
uiStore.notifySuccess('Playlist attachée à la queue !')
|
uiStore.notifySuccess('Playlist attachée à la queue !')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
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)
|
// Actions handlers pour les items (tracks)
|
||||||
async function handlePlayItem(itemId: string, rendererId: string) {
|
async function handlePlayItem(itemId: string, rendererId: string) {
|
||||||
try {
|
try {
|
||||||
await renderersStore.playContent(rendererId, props.serverId, itemId)
|
await playContent(rendererId, props.serverId, itemId)
|
||||||
uiStore.notifySuccess('Lecture démarrée !')
|
uiStore.notifySuccess('Lecture démarrée !')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
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) {
|
async function handleQueueItem(itemId: string, rendererId: string) {
|
||||||
try {
|
try {
|
||||||
await renderersStore.addToQueue(rendererId, props.serverId, itemId)
|
await addToQueue(rendererId, props.serverId, itemId)
|
||||||
uiStore.notifySuccess('Ajouté à la queue !')
|
uiStore.notifySuccess('Ajouté à la queue !')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
@@ -170,7 +177,7 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
|||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
<div v-else-if="error" class="browser-error">
|
<div v-else-if="error" class="browser-error">
|
||||||
<p class="error-message">{{ error }}</p>
|
<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
|
Réessayer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, toRef } from 'vue'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||||
import { useUIStore } from '@/stores/ui'
|
import { useUIStore } from '@/stores/ui'
|
||||||
import { Link, Unlink } from 'lucide-vue-next'
|
import { Link, Unlink } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -8,15 +8,15 @@ const props = defineProps<{
|
|||||||
rendererId: string
|
rendererId: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const renderersStore = useRenderersStore()
|
const { binding } = useRenderer(toRef(props, 'rendererId'))
|
||||||
|
const { detachPlaylist } = useRenderers()
|
||||||
const uiStore = useUIStore()
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
const binding = computed(() => renderersStore.getBindingById(props.rendererId))
|
|
||||||
const isAttached = computed(() => !!binding.value)
|
const isAttached = computed(() => !!binding.value)
|
||||||
|
|
||||||
async function handleDetach() {
|
async function handleDetach() {
|
||||||
try {
|
try {
|
||||||
await renderersStore.detachPlaylist(props.rendererId)
|
await detachPlaylist(props.rendererId)
|
||||||
uiStore.notifySuccess('Playlist détachée avec succès')
|
uiStore.notifySuccess('Playlist détachée avec succès')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
uiStore.notifyError(`Impossible de détacher la playlist: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
uiStore.notifyError(`Impossible de détacher la playlist: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, nextTick } from 'vue'
|
import { computed, ref, watch, nextTick, toRef } from 'vue'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderer } from '@/composables/useRenderers'
|
||||||
import QueueItem from './QueueItem.vue'
|
import QueueItem from './QueueItem.vue'
|
||||||
import { Link } from 'lucide-vue-next'
|
import { Link } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -8,10 +8,7 @@ const props = defineProps<{
|
|||||||
rendererId: string
|
rendererId: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const renderersStore = useRenderersStore()
|
const { queue, binding } = useRenderer(toRef(props, 'rendererId'))
|
||||||
|
|
||||||
const queue = computed(() => renderersStore.getQueueById(props.rendererId))
|
|
||||||
const binding = computed(() => renderersStore.getBindingById(props.rendererId))
|
|
||||||
|
|
||||||
const isAttached = computed(() => !!binding.value)
|
const isAttached = computed(() => !!binding.value)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, toRef } from 'vue'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||||
import { useUIStore } from '@/stores/ui'
|
import { useUIStore } from '@/stores/ui'
|
||||||
import { Play, Pause, Square, SkipForward } from 'lucide-vue-next'
|
import { Play, Pause, Square, SkipForward } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -8,20 +8,17 @@ const props = defineProps<{
|
|||||||
rendererId: string
|
rendererId: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const renderersStore = useRenderersStore()
|
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||||
|
const { resumeOrPlayFromQueue, pause, stop, next } = useRenderers()
|
||||||
const uiStore = useUIStore()
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
const state = computed(() => renderersStore.getStateById(props.rendererId))
|
|
||||||
const isPlaying = computed(() => state.value?.transport_state === 'PLAYING')
|
const isPlaying = computed(() => state.value?.transport_state === 'PLAYING')
|
||||||
const isPaused = computed(() => state.value?.transport_state === 'PAUSED')
|
const isPaused = computed(() => state.value?.transport_state === 'PAUSED')
|
||||||
const isStopped = computed(() => state.value?.transport_state === 'STOPPED' || state.value?.transport_state === 'NO_MEDIA')
|
const isStopped = computed(() => state.value?.transport_state === 'STOPPED' || state.value?.transport_state === 'NO_MEDIA')
|
||||||
|
|
||||||
async function handlePlay() {
|
async function handlePlay() {
|
||||||
try {
|
try {
|
||||||
// Utilise resumeOrPlayFromQueue qui choisit intelligemment entre:
|
await resumeOrPlayFromQueue(props.rendererId)
|
||||||
// - Resume (si PAUSED)
|
|
||||||
// - Play from queue (si STOPPED/NO_MEDIA avec queue non vide)
|
|
||||||
await renderersStore.resumeOrPlayFromQueue(props.rendererId)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
uiStore.notifyError(`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
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() {
|
async function handlePause() {
|
||||||
try {
|
try {
|
||||||
await renderersStore.pause(props.rendererId)
|
await pause(props.rendererId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
uiStore.notifyError(`Impossible de mettre en pause: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
uiStore.notifyError(`Impossible de mettre en pause: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||||
}
|
}
|
||||||
@@ -37,7 +34,7 @@ async function handlePause() {
|
|||||||
|
|
||||||
async function handleStop() {
|
async function handleStop() {
|
||||||
try {
|
try {
|
||||||
await renderersStore.stop(props.rendererId)
|
await stop(props.rendererId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
uiStore.notifyError(`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
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() {
|
async function handleNext() {
|
||||||
try {
|
try {
|
||||||
await renderersStore.next(props.rendererId)
|
await next(props.rendererId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
uiStore.notifyError(`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
uiStore.notifyError(`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, watch, toRef } from 'vue'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||||
import { useUIStore } from '@/stores/ui'
|
import { useUIStore } from '@/stores/ui'
|
||||||
import { Volume2, VolumeX } from 'lucide-vue-next'
|
import { Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -8,10 +8,10 @@ const props = defineProps<{
|
|||||||
rendererId: string
|
rendererId: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const renderersStore = useRenderersStore()
|
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||||
|
const { setVolume, toggleMute } = useRenderers()
|
||||||
const uiStore = useUIStore()
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
const state = computed(() => renderersStore.getStateById(props.rendererId))
|
|
||||||
const localVolume = ref(state.value?.volume ?? 50)
|
const localVolume = ref(state.value?.volume ?? 50)
|
||||||
|
|
||||||
// Synchroniser localVolume avec le state
|
// Synchroniser localVolume avec le state
|
||||||
@@ -34,7 +34,7 @@ function handleVolumeChange(event: Event) {
|
|||||||
|
|
||||||
debounceTimer = window.setTimeout(async () => {
|
debounceTimer = window.setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await renderersStore.setVolume(props.rendererId, localVolume.value)
|
await setVolume(props.rendererId, localVolume.value)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
uiStore.notifyError(`Impossible de régler le volume: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
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() {
|
async function handleToggleMute() {
|
||||||
try {
|
try {
|
||||||
await renderersStore.toggleMute(props.rendererId)
|
await toggleMute(props.rendererId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
uiStore.notifyError(`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
uiStore.notifyError(`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||||
}
|
}
|
||||||
|
|||||||
183
pmoapp/webapp/src/composables/useMediaServers.ts
Normal file
183
pmoapp/webapp/src/composables/useMediaServers.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
340
pmoapp/webapp/src/composables/useRenderers.ts
Normal file
340
pmoapp/webapp/src/composables/useRenderers.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,12 @@ import { createPinia } from "pinia";
|
|||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
|
|
||||||
// Stores
|
// Service SSE (les composables se connectent automatiquement)
|
||||||
import { useRenderersStore } from "./stores/renderers";
|
|
||||||
import { useMediaServersStore } from "./stores/mediaServers";
|
|
||||||
import { useUIStore } from "./stores/ui";
|
|
||||||
|
|
||||||
// Service SSE
|
|
||||||
import { sse } from "./services/pmocontrol/sse";
|
import { sse } from "./services/pmocontrol/sse";
|
||||||
|
|
||||||
|
// Store UI (garde UIStore pour les notifications et état UI global)
|
||||||
|
import { useUIStore } from "./stores/ui";
|
||||||
|
|
||||||
// Styles
|
// Styles
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
import "./assets/styles/variables.css";
|
import "./assets/styles/variables.css";
|
||||||
@@ -27,35 +25,16 @@ app.use(router);
|
|||||||
// Monter l'application
|
// Monter l'application
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
|
||||||
// Après montage, initialiser SSE et connecter aux stores
|
// Après montage, initialiser SSE
|
||||||
const renderersStore = useRenderersStore();
|
|
||||||
const mediaServersStore = useMediaServersStore();
|
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
// Connecter SSE aux stores
|
// Les composables se connectent automatiquement à SSE
|
||||||
// Note: Les métadonnées proviennent de l'API (current_track dans RendererState)
|
// Ils gèrent eux-mêmes le re-fetch lors des événements
|
||||||
// Les événements SSE ne servent qu'à notifier les changements
|
|
||||||
sse.onRendererEvent((event) => {
|
|
||||||
renderersStore.updateFromSSE(event);
|
|
||||||
});
|
|
||||||
|
|
||||||
sse.onMediaServerEvent((event) => {
|
|
||||||
mediaServersStore.updateFromSSE(event);
|
|
||||||
});
|
|
||||||
|
|
||||||
sse.onConnectionChange((connected) => {
|
sse.onConnectionChange((connected) => {
|
||||||
uiStore.setSSEConnected(connected);
|
uiStore.setSSEConnected(connected);
|
||||||
if (connected) {
|
if (connected) {
|
||||||
console.log("[App] SSE connecté - Chargement des données initiales");
|
console.log("[App] SSE connecté");
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,26 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderers } from '@/composables/useRenderers'
|
||||||
import { useMediaServersStore } from '@/stores/mediaServers'
|
import { useMediaServers } from '@/composables/useMediaServers'
|
||||||
import RendererCard from '@/components/pmocontrol/RendererCard.vue'
|
import RendererCard from '@/components/pmocontrol/RendererCard.vue'
|
||||||
import MediaServerCard from '@/components/pmocontrol/MediaServerCard.vue'
|
import MediaServerCard from '@/components/pmocontrol/MediaServerCard.vue'
|
||||||
import { Radio, Server } from 'lucide-vue-next'
|
import { Radio, Server } from 'lucide-vue-next'
|
||||||
|
|
||||||
const renderersStore = useRenderersStore()
|
const {
|
||||||
const mediaServersStore = useMediaServersStore()
|
allRenderers: renderers,
|
||||||
|
onlineRenderers,
|
||||||
|
getStateById,
|
||||||
|
fetchRenderers,
|
||||||
|
fetchRendererState
|
||||||
|
} = useRenderers()
|
||||||
|
|
||||||
const renderers = computed(() => Array.from(renderersStore.renderers.values()))
|
const {
|
||||||
const onlineRenderers = computed(() => renderersStore.onlineRenderers)
|
allServers: mediaServers,
|
||||||
const mediaServers = computed(() => Array.from(mediaServersStore.servers.values()))
|
onlineServers,
|
||||||
const onlineServers = computed(() => mediaServersStore.onlineServers)
|
fetchServers
|
||||||
|
} = useMediaServers()
|
||||||
|
|
||||||
// Charger les données au montage (si pas encore chargées par SSE)
|
// Charger les données au montage
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (renderers.value.length === 0) {
|
await fetchRenderers()
|
||||||
await renderersStore.fetchRenderers()
|
await fetchServers()
|
||||||
}
|
|
||||||
if (mediaServers.value.length === 0) {
|
// Charger les états de tous les renderers pour afficher les covers
|
||||||
await mediaServersStore.fetchServers()
|
for (const renderer of renderers.value) {
|
||||||
|
fetchRendererState(renderer.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -56,13 +63,13 @@ onMounted(async () => {
|
|||||||
v-for="renderer in renderers"
|
v-for="renderer in renderers"
|
||||||
:key="renderer.id"
|
:key="renderer.id"
|
||||||
:renderer="renderer"
|
:renderer="renderer"
|
||||||
:state="renderersStore.getStateById(renderer.id) ?? null"
|
:state="getStateById(renderer.id) ?? null"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
<p>Aucun renderer découvert</p>
|
<p>Aucun renderer découvert</p>
|
||||||
<button class="btn btn-secondary" @click="renderersStore.fetchRenderers()">
|
<button class="btn btn-secondary" @click="fetchRenderers(true)">
|
||||||
Actualiser
|
Actualiser
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +94,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
<p>Aucun serveur de médias découvert</p>
|
<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
|
Actualiser
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch, ref } from 'vue'
|
import { computed, watch, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useMediaServersStore } from '@/stores/mediaServers'
|
import { useMediaServers } from '@/composables/useMediaServers'
|
||||||
import MediaBrowser from '@/components/pmocontrol/MediaBrowser.vue'
|
import MediaBrowser from '@/components/pmocontrol/MediaBrowser.vue'
|
||||||
import { ArrowLeft, Server } from 'lucide-vue-next'
|
import { ArrowLeft, Server } from 'lucide-vue-next'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const mediaServersStore = useMediaServersStore()
|
const { getServerById } = useMediaServers()
|
||||||
|
|
||||||
const serverId = computed(() => route.params.serverId as string)
|
const serverId = computed(() => route.params.serverId as string)
|
||||||
const containerId = ref(route.query.container as string || '0')
|
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
|
// Watcher sur query.container pour mettre à jour containerId
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||||
import { useUIStore } from '@/stores/ui'
|
import { useUIStore } from '@/stores/ui'
|
||||||
import CurrentTrack from '@/components/pmocontrol/CurrentTrack.vue'
|
import CurrentTrack from '@/components/pmocontrol/CurrentTrack.vue'
|
||||||
import TransportControls from '@/components/pmocontrol/TransportControls.vue'
|
import TransportControls from '@/components/pmocontrol/TransportControls.vue'
|
||||||
@@ -20,12 +20,11 @@ import type { OpenHomePlaylistSnapshot } from '@/services/pmocontrol/types'
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const renderersStore = useRenderersStore()
|
|
||||||
const uiStore = useUIStore()
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
const rendererId = computed(() => route.params.id as string)
|
const rendererId = computed(() => route.params.id as string)
|
||||||
const renderer = computed(() => renderersStore.getRendererById(rendererId.value))
|
const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value))
|
||||||
const state = computed(() => renderersStore.getStateById(rendererId.value))
|
const { fetchRenderers } = useRenderers()
|
||||||
const openHomeSupported = computed(() => {
|
const openHomeSupported = computed(() => {
|
||||||
const current = renderer.value
|
const current = renderer.value
|
||||||
if (!current) return false
|
if (!current) return false
|
||||||
@@ -48,13 +47,11 @@ onMounted(async () => {
|
|||||||
// Indiquer à uiStore quel renderer est sélectionné
|
// Indiquer à uiStore quel renderer est sélectionné
|
||||||
uiStore.selectRenderer(rendererId.value)
|
uiStore.selectRenderer(rendererId.value)
|
||||||
|
|
||||||
|
// Charger toutes les données du renderer
|
||||||
if (!renderer.value) {
|
if (!renderer.value) {
|
||||||
await renderersStore.fetchRenderers()
|
await fetchRenderers()
|
||||||
}
|
}
|
||||||
if (!state.value) {
|
await refresh()
|
||||||
await renderersStore.fetchRendererState(rendererId.value)
|
|
||||||
}
|
|
||||||
await renderersStore.fetchQueue(rendererId.value)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -813,6 +813,23 @@ impl ControlPoint {
|
|||||||
uri = item.uri.as_str(),
|
uri = item.uri.as_str(),
|
||||||
"Queue playback started (current item)"
|
"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
|
self.runtime
|
||||||
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -899,6 +916,22 @@ impl ControlPoint {
|
|||||||
return Err(err);
|
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
|
self.runtime
|
||||||
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
||||||
debug!(
|
debug!(
|
||||||
|
|||||||
@@ -162,10 +162,11 @@ async fn get_renderer_state(
|
|||||||
.map(state_to_string)
|
.map(state_to_string)
|
||||||
.unwrap_or_else(|| "UNKNOWN".to_string());
|
.unwrap_or_else(|| "UNKNOWN".to_string());
|
||||||
|
|
||||||
// Position et durée
|
// Position, durée et métadonnées depuis GetPositionInfo
|
||||||
let (position_ms, duration_ms) = renderer
|
let position_info = renderer.playback_position().ok();
|
||||||
.playback_position()
|
|
||||||
.ok()
|
let (position_ms, duration_ms) = position_info
|
||||||
|
.as_ref()
|
||||||
.and_then(|pos| {
|
.and_then(|pos| {
|
||||||
let position = parse_hms_to_ms(pos.rel_time.as_deref());
|
let position = parse_hms_to_ms(pos.rel_time.as_deref());
|
||||||
let duration = parse_hms_to_ms(pos.track_duration.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));
|
.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
|
// Volume et mute
|
||||||
let volume = renderer
|
let volume = renderer
|
||||||
.volume()
|
.volume()
|
||||||
@@ -180,10 +206,10 @@ async fn get_renderer_state(
|
|||||||
.and_then(|v| u8::try_from(v).ok());
|
.and_then(|v| u8::try_from(v).ok());
|
||||||
let mute = renderer.mute().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)
|
time::timeout(STATE_QUERY_TIMEOUT, state_task)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
@@ -238,10 +264,22 @@ async fn get_renderer_state(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Current track metadata from in-memory snapshot (non-blocking, fast)
|
// Current track metadata:
|
||||||
let current_track = state
|
// 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
|
.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 {
|
.map(|metadata| CurrentTrackMetadata {
|
||||||
title: metadata.title,
|
title: metadata.title,
|
||||||
artist: metadata.artist,
|
artist: metadata.artist,
|
||||||
|
|||||||
Reference in New Issue
Block a user