push-pqqsxyupswry #21
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'}`)
|
||||
}
|
||||
|
||||
@@ -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'}`)
|
||||
}
|
||||
|
||||
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 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é");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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">
|
||||
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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user