webui control point step 2
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -41,4 +41,5 @@ test_upnp*.cargo/
|
|||||||
setup-env.sh
|
setup-env.sh
|
||||||
cache
|
cache
|
||||||
gupnp-tools
|
gupnp-tools
|
||||||
pmocontrol_[0_9]*.txt
|
pmocontrol_[0_9]*.txt
|
||||||
|
webapp_[0_9]*.txt
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -3263,6 +3263,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-log 0.1.4",
|
"tracing-log 0.1.4",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -3492,6 +3493,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
|
|||||||
@@ -122,7 +122,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
info!("✅ PMOMusic is ready!");
|
info!("✅ PMOMusic is ready!");
|
||||||
info!("Press Ctrl+C to stop...");
|
info!("Press Ctrl+C to stop...");
|
||||||
|
|
||||||
|
// Attendre le signal Ctrl+C et l'arrêt du serveur HTTP
|
||||||
server.write().await.wait().await;
|
server.write().await.wait().await;
|
||||||
|
|
||||||
Ok(())
|
// Le serveur HTTP est arrêté, mais des threads (ControlPoint, etc.) peuvent encore tourner
|
||||||
|
// Attendre 2 secondes pour laisser le temps aux threads de se terminer
|
||||||
|
info!("Waiting for background threads to finish...");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
// Forcer l'arrêt du processus (les threads du ControlPoint tournent en boucle infinie)
|
||||||
|
info!("✅ PMOMusic stopped");
|
||||||
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -25,12 +25,16 @@
|
|||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Notifications Toast -->
|
||||||
|
<NotificationToast />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import NotificationToast from '@/components/NotificationToast.vue'
|
||||||
|
|
||||||
const showDebugMenu = ref(false)
|
const showDebugMenu = ref(false)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
172
pmoapp/webapp/src/components/NotificationToast.vue
Normal file
172
pmoapp/webapp/src/components/NotificationToast.vue
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
|
import { AlertCircle, CheckCircle, Info, X, AlertTriangle } from 'lucide-vue-next'
|
||||||
|
import type { Notification } from '@/stores/ui'
|
||||||
|
|
||||||
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
|
function removeNotification(id: string) {
|
||||||
|
uiStore.removeNotification(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIcon(type: Notification['type']) {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return CheckCircle
|
||||||
|
case 'error':
|
||||||
|
return AlertCircle
|
||||||
|
case 'warning':
|
||||||
|
return AlertTriangle
|
||||||
|
case 'info':
|
||||||
|
return Info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeClass(type: Notification['type']) {
|
||||||
|
return `notification-${type}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="notification-container">
|
||||||
|
<TransitionGroup name="notification-list">
|
||||||
|
<div
|
||||||
|
v-for="notification in uiStore.notifications"
|
||||||
|
:key="notification.id"
|
||||||
|
:class="['notification', getTypeClass(notification.type)]"
|
||||||
|
>
|
||||||
|
<component :is="getIcon(notification.type)" :size="20" class="notification-icon" />
|
||||||
|
<p class="notification-message">{{ notification.message }}</p>
|
||||||
|
<button
|
||||||
|
class="notification-close"
|
||||||
|
@click="removeNotification(notification.id)"
|
||||||
|
title="Fermer"
|
||||||
|
>
|
||||||
|
<X :size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notification-container {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--spacing-lg);
|
||||||
|
right: var(--spacing-lg);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
max-width: 400px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
pointer-events: auto;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close:hover {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type-specific styles */
|
||||||
|
.notification-success {
|
||||||
|
border-left: 4px solid var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-success .notification-icon {
|
||||||
|
color: var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-error {
|
||||||
|
border-left: 4px solid var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-error .notification-icon {
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-warning {
|
||||||
|
border-left: 4px solid var(--status-paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-warning .notification-icon {
|
||||||
|
color: var(--status-paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-info {
|
||||||
|
border-left: 4px solid var(--status-transitioning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-info .notification-icon {
|
||||||
|
color: var(--status-transitioning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
.notification-list-enter-active,
|
||||||
|
.notification-list-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.notification-container {
|
||||||
|
top: var(--spacing-md);
|
||||||
|
right: var(--spacing-md);
|
||||||
|
left: var(--spacing-md);
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
import { Play, ListPlus, Link, ChevronRight } from 'lucide-vue-next'
|
import { Play, ListPlus, ChevronRight } from 'lucide-vue-next'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
type: 'container' | 'item'
|
type: 'container' | 'item'
|
||||||
@@ -12,16 +12,15 @@ defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
playNow: [rendererId: string]
|
playNow: [rendererId: string]
|
||||||
addToQueue: [rendererId: string]
|
addToQueue: [rendererId: string]
|
||||||
attachPlaylist: [rendererId: string]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const renderersStore = useRenderersStore()
|
const renderersStore = useRenderersStore()
|
||||||
const showMenu = ref(false)
|
const showMenu = ref(false)
|
||||||
const showRendererSubmenu = ref<'play' | 'queue' | 'attach' | null>(null)
|
const showRendererSubmenu = ref<'play' | 'queue' | null>(null)
|
||||||
|
|
||||||
const onlineRenderers = computed(() => renderersStore.onlineRenderers)
|
const onlineRenderers = computed(() => renderersStore.onlineRenderers)
|
||||||
|
|
||||||
function handleAction(action: 'play' | 'queue' | 'attach', rendererId: string) {
|
function handleAction(action: 'play' | 'queue', rendererId: string) {
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
showRendererSubmenu.value = null
|
showRendererSubmenu.value = null
|
||||||
|
|
||||||
@@ -32,9 +31,6 @@ function handleAction(action: 'play' | 'queue' | 'attach', rendererId: string) {
|
|||||||
case 'queue':
|
case 'queue':
|
||||||
emit('addToQueue', rendererId)
|
emit('addToQueue', rendererId)
|
||||||
break
|
break
|
||||||
case 'attach':
|
|
||||||
emit('attachPlaylist', rendererId)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,32 +99,6 @@ function toggleMenu() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Attach Playlist (only for containers) -->
|
|
||||||
<div
|
|
||||||
v-if="type === 'container'"
|
|
||||||
class="menu-item"
|
|
||||||
@mouseenter="showRendererSubmenu = 'attach'"
|
|
||||||
@mouseleave="showRendererSubmenu = null"
|
|
||||||
>
|
|
||||||
<Link :size="16" />
|
|
||||||
<span>Attacher la queue de</span>
|
|
||||||
<ChevronRight :size="16" class="submenu-arrow" />
|
|
||||||
|
|
||||||
<div v-if="showRendererSubmenu === 'attach'" class="renderer-submenu">
|
|
||||||
<div
|
|
||||||
v-for="renderer in onlineRenderers"
|
|
||||||
:key="renderer.id"
|
|
||||||
class="submenu-item"
|
|
||||||
@click="handleAction('attach', renderer.id)"
|
|
||||||
>
|
|
||||||
{{ renderer.friendly_name }}
|
|
||||||
</div>
|
|
||||||
<div v-if="onlineRenderers.length === 0" class="submenu-empty">
|
|
||||||
Aucun renderer disponible
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Backdrop to close menu -->
|
<!-- Backdrop to close menu -->
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const emit = defineEmits<{
|
|||||||
browse: [containerId: string]
|
browse: [containerId: string]
|
||||||
playNow: [containerId: string, rendererId: string]
|
playNow: [containerId: string, rendererId: string]
|
||||||
addToQueue: [containerId: string, rendererId: string]
|
addToQueue: [containerId: string, rendererId: string]
|
||||||
attachPlaylist: [containerId: string, rendererId: string]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const iconComponent = computed(() => {
|
const iconComponent = computed(() => {
|
||||||
@@ -44,10 +43,6 @@ function handlePlayNow(rendererId: string) {
|
|||||||
function handleAddToQueue(rendererId: string) {
|
function handleAddToQueue(rendererId: string) {
|
||||||
emit('addToQueue', props.entry.id, rendererId)
|
emit('addToQueue', props.entry.id, rendererId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAttachPlaylist(rendererId: string) {
|
|
||||||
emit('attachPlaylist', props.entry.id, rendererId)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -76,7 +71,6 @@ function handleAttachPlaylist(rendererId: string) {
|
|||||||
:server-id="serverId"
|
:server-id="serverId"
|
||||||
@play-now="handlePlayNow"
|
@play-now="handlePlayNow"
|
||||||
@add-to-queue="handleAddToQueue"
|
@add-to-queue="handleAddToQueue"
|
||||||
@attach-playlist="handleAttachPlaylist"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useMediaServersStore } from '@/stores/mediaServers'
|
import { useMediaServersStore } from '@/stores/mediaServers'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
import { useUIStore } from '@/stores/ui'
|
import { useUIStore } from '@/stores/ui'
|
||||||
@@ -18,6 +18,12 @@ const mediaServersStore = useMediaServersStore()
|
|||||||
const renderersStore = useRenderersStore()
|
const renderersStore = useRenderersStore()
|
||||||
const uiStore = useUIStore()
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
|
// Flags pour gérer le rechargement automatique avec debounce et cooldown
|
||||||
|
const isRefreshing = ref(false)
|
||||||
|
const refreshTimeoutId = ref<number | null>(null)
|
||||||
|
const lastRefreshTime = ref<number>(0)
|
||||||
|
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)
|
mediaServersStore.getBrowseCached(props.serverId, props.containerId)
|
||||||
)
|
)
|
||||||
@@ -46,6 +52,49 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
|
||||||
|
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
|
||||||
|
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
|
||||||
|
// Utilise un debounce de 3 secondes pour regrouper les multiples invalidations
|
||||||
|
// et un cooldown de 5 secondes pour éviter les rechargements successifs
|
||||||
|
watch(
|
||||||
|
() => browseData.value,
|
||||||
|
(data) => {
|
||||||
|
// Si browseData devient undefined alors que containerId est présent,
|
||||||
|
// et qu'on n'est pas déjà en train de charger, planifier un rechargement
|
||||||
|
if (!data && props.containerId && !loading.value) {
|
||||||
|
// Vérifier le cooldown: ignorer si on a rechargé il y a moins de 5 secondes
|
||||||
|
const timeSinceLastRefresh = Date.now() - lastRefreshTime.value
|
||||||
|
if (timeSinceLastRefresh < REFRESH_COOLDOWN_MS) {
|
||||||
|
console.log(
|
||||||
|
`[MediaBrowser] Cache invalidé mais cooldown actif (${Math.round((REFRESH_COOLDOWN_MS - timeSinceLastRefresh) / 1000)}s restantes), rechargement ignoré`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annuler tout timeout en cours
|
||||||
|
if (refreshTimeoutId.value !== null) {
|
||||||
|
clearTimeout(refreshTimeoutId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Planifier le rechargement après 3 secondes
|
||||||
|
// Cela permet de regrouper plusieurs événements SSE successifs
|
||||||
|
refreshTimeoutId.value = window.setTimeout(async () => {
|
||||||
|
if (!isRefreshing.value) {
|
||||||
|
console.log(
|
||||||
|
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement après debounce...`
|
||||||
|
)
|
||||||
|
isRefreshing.value = true
|
||||||
|
await mediaServersStore.browseContainer(props.serverId, props.containerId)
|
||||||
|
lastRefreshTime.value = Date.now() // Enregistrer le moment du rechargement
|
||||||
|
isRefreshing.value = false
|
||||||
|
refreshTimeoutId.value = null
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
navigate: [containerId: string]
|
navigate: [containerId: string]
|
||||||
}>()
|
}>()
|
||||||
@@ -58,36 +107,47 @@ function handleBrowseContainer(containerId: string) {
|
|||||||
emit('navigate', containerId)
|
emit('navigate', containerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actions handlers
|
// Actions handlers pour les containers (playlists/albums)
|
||||||
async function handlePlayNow(entryId: string, rendererId: string) {
|
async function handlePlayContainer(containerId: string, rendererId: string) {
|
||||||
try {
|
try {
|
||||||
// TODO: Implémenter l'action "play now" quand l'API sera disponible
|
await renderersStore.attachAndPlayPlaylist(rendererId, props.serverId, containerId)
|
||||||
console.log('Play now:', entryId, 'on', rendererId)
|
uiStore.notifySuccess('Lecture de la playlist démarrée !')
|
||||||
uiStore.addNotification('info', 'Fonctionnalité "Lire maintenant" pas encore implémentée dans l\'API')
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur play now:', err)
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
uiStore.addNotification('error', 'Erreur lors de la lecture')
|
uiStore.notifyError(`Erreur lors de la lecture de la playlist: ${message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddToQueue(entryId: string, rendererId: string) {
|
async function handleQueueContainer(containerId: string, rendererId: string) {
|
||||||
try {
|
|
||||||
// TODO: Implémenter l'action "add to queue" quand l'API sera disponible
|
|
||||||
console.log('Add to queue:', entryId, 'on', rendererId)
|
|
||||||
uiStore.addNotification('info', 'Fonctionnalité "Ajouter à la queue" pas encore implémentée dans l\'API')
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Erreur add to queue:', err)
|
|
||||||
uiStore.addNotification('error', 'Erreur lors de l\'ajout à la queue')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAttachPlaylist(containerId: string, rendererId: string) {
|
|
||||||
try {
|
try {
|
||||||
|
// Attacher la playlist (sans démarrer la lecture)
|
||||||
await renderersStore.attachPlaylist(rendererId, props.serverId, containerId)
|
await renderersStore.attachPlaylist(rendererId, props.serverId, containerId)
|
||||||
uiStore.addNotification('success', `Queue attachée à la playlist !`)
|
await renderersStore.fetchQueue(rendererId)
|
||||||
|
uiStore.notifySuccess('Playlist attachée à la queue !')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur attach playlist:', err)
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
uiStore.addNotification('error', 'Erreur lors de l\'attachement')
|
uiStore.notifyError(`Erreur lors de l'ajout de la playlist: ${message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions handlers pour les items (tracks)
|
||||||
|
async function handlePlayItem(itemId: string, rendererId: string) {
|
||||||
|
try {
|
||||||
|
await renderersStore.playContent(rendererId, props.serverId, itemId)
|
||||||
|
uiStore.notifySuccess('Lecture démarrée !')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
uiStore.notifyError(`Erreur lors de la lecture: ${message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleQueueItem(itemId: string, rendererId: string) {
|
||||||
|
try {
|
||||||
|
await renderersStore.addToQueue(rendererId, props.serverId, itemId)
|
||||||
|
uiStore.notifySuccess('Ajouté à la queue !')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
uiStore.notifyError(`Erreur lors de l'ajout à la queue: ${message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -127,9 +187,8 @@ async function handleAttachPlaylist(containerId: string, rendererId: string) {
|
|||||||
:entry="container"
|
:entry="container"
|
||||||
:server-id="serverId"
|
:server-id="serverId"
|
||||||
@browse="handleBrowseContainer"
|
@browse="handleBrowseContainer"
|
||||||
@play-now="handlePlayNow"
|
@play-now="handlePlayContainer"
|
||||||
@add-to-queue="handleAddToQueue"
|
@add-to-queue="handleQueueContainer"
|
||||||
@attach-playlist="handleAttachPlaylist"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,8 +202,8 @@ async function handleAttachPlaylist(containerId: string, rendererId: string) {
|
|||||||
:key="item.id"
|
:key="item.id"
|
||||||
:entry="item"
|
:entry="item"
|
||||||
:server-id="serverId"
|
:server-id="serverId"
|
||||||
@play-now="handlePlayNow"
|
@play-now="handlePlayItem"
|
||||||
@add-to-queue="handleAddToQueue"
|
@add-to-queue="handleQueueItem"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
import { Link, Unlink } from 'lucide-vue-next'
|
import { Link, Unlink } from 'lucide-vue-next'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -8,6 +9,7 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const renderersStore = useRenderersStore()
|
const renderersStore = useRenderersStore()
|
||||||
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
const binding = computed(() => renderersStore.getBindingById(props.rendererId))
|
const binding = computed(() => renderersStore.getBindingById(props.rendererId))
|
||||||
const isAttached = computed(() => !!binding.value)
|
const isAttached = computed(() => !!binding.value)
|
||||||
@@ -15,8 +17,9 @@ const isAttached = computed(() => !!binding.value)
|
|||||||
async function handleDetach() {
|
async function handleDetach() {
|
||||||
try {
|
try {
|
||||||
await renderersStore.detachPlaylist(props.rendererId)
|
await renderersStore.detachPlaylist(props.rendererId)
|
||||||
|
uiStore.notifySuccess('Playlist détachée avec succès')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur detach:', error)
|
uiStore.notifyError(`Impossible de détacher la playlist: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -63,14 +66,13 @@ async function handleDetach() {
|
|||||||
<div class="help-section">
|
<div class="help-section">
|
||||||
<p class="help-text">
|
<p class="help-text">
|
||||||
<strong>📌 Playlist Dynamique</strong><br>
|
<strong>📌 Playlist Dynamique</strong><br>
|
||||||
Attachez cette queue à une playlist/album du serveur média.
|
La queue se synchronisera automatiquement avec la playlist/album du serveur.
|
||||||
La queue se synchronisera automatiquement : les pistes ajoutées ou
|
Les pistes ajoutées ou retirées sur le serveur apparaîtront ici en temps réel.
|
||||||
retirées sur le serveur apparaîtront ici en temps réel.
|
|
||||||
</p>
|
</p>
|
||||||
<p class="help-text help-text-warning">
|
<p class="help-text">
|
||||||
⚠️ <em>Fonctionnalité en cours de développement</em> :
|
<strong>💡 Comment activer ?</strong><br>
|
||||||
Les actions "Attacher" ne sont pas encore disponibles dans le navigateur de médias.
|
Naviguez vers un serveur de médias, trouvez une playlist/album,
|
||||||
Pour l'instant, utilisez l'API REST directement.
|
et cliquez sur ⋮ puis "Lire maintenant". Le binding se fera automatiquement !
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
import { Play, Pause, Square, SkipForward } from 'lucide-vue-next'
|
import { Play, Pause, Square, SkipForward } from 'lucide-vue-next'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -8,6 +9,7 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const renderersStore = useRenderersStore()
|
const renderersStore = useRenderersStore()
|
||||||
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
const state = computed(() => renderersStore.getStateById(props.rendererId))
|
const state = computed(() => renderersStore.getStateById(props.rendererId))
|
||||||
const isPlaying = computed(() => state.value?.transport_state === 'PLAYING')
|
const isPlaying = computed(() => state.value?.transport_state === 'PLAYING')
|
||||||
@@ -16,9 +18,12 @@ const isStopped = computed(() => state.value?.transport_state === 'STOPPED' || s
|
|||||||
|
|
||||||
async function handlePlay() {
|
async function handlePlay() {
|
||||||
try {
|
try {
|
||||||
await renderersStore.play(props.rendererId)
|
// 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)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur play:', error)
|
uiStore.notifyError(`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +31,7 @@ async function handlePause() {
|
|||||||
try {
|
try {
|
||||||
await renderersStore.pause(props.rendererId)
|
await renderersStore.pause(props.rendererId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur pause:', error)
|
uiStore.notifyError(`Impossible de mettre en pause: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +39,7 @@ async function handleStop() {
|
|||||||
try {
|
try {
|
||||||
await renderersStore.stop(props.rendererId)
|
await renderersStore.stop(props.rendererId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur stop:', error)
|
uiStore.notifyError(`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +47,7 @@ async function handleNext() {
|
|||||||
try {
|
try {
|
||||||
await renderersStore.next(props.rendererId)
|
await renderersStore.next(props.rendererId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur next:', error)
|
uiStore.notifyError(`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
import { Volume2, VolumeX } from 'lucide-vue-next'
|
import { Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -8,6 +9,7 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const renderersStore = useRenderersStore()
|
const renderersStore = useRenderersStore()
|
||||||
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
const state = computed(() => renderersStore.getStateById(props.rendererId))
|
const state = computed(() => renderersStore.getStateById(props.rendererId))
|
||||||
const localVolume = ref(state.value?.volume ?? 50)
|
const localVolume = ref(state.value?.volume ?? 50)
|
||||||
@@ -34,7 +36,7 @@ function handleVolumeChange(event: Event) {
|
|||||||
try {
|
try {
|
||||||
await renderersStore.setVolume(props.rendererId, localVolume.value)
|
await renderersStore.setVolume(props.rendererId, localVolume.value)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur set volume:', error)
|
uiStore.notifyError(`Impossible de régler le volume: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||||
}
|
}
|
||||||
debounceTimer = null
|
debounceTimer = null
|
||||||
}, 300)
|
}, 300)
|
||||||
@@ -44,7 +46,7 @@ async function handleToggleMute() {
|
|||||||
try {
|
try {
|
||||||
await renderersStore.toggleMute(props.rendererId)
|
await renderersStore.toggleMute(props.rendererId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur toggle mute:', error)
|
uiStore.notifyError(`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ sse.onConnectionChange((connected) => {
|
|||||||
// Charger les données initiales
|
// Charger les données initiales
|
||||||
renderersStore.fetchRenderers();
|
renderersStore.fetchRenderers();
|
||||||
mediaServersStore.fetchServers();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
BrowseResponse,
|
BrowseResponse,
|
||||||
VolumeSetRequest,
|
VolumeSetRequest,
|
||||||
AttachPlaylistRequest,
|
AttachPlaylistRequest,
|
||||||
|
PlayContentRequest,
|
||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
ErrorResponse
|
ErrorResponse
|
||||||
} from './types'
|
} from './types'
|
||||||
@@ -117,6 +118,16 @@ class PMOControlAPI {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reprend la lecture depuis le morceau actuel de la queue
|
||||||
|
* POST /api/control/renderers/{id}/resume
|
||||||
|
*/
|
||||||
|
async resume(id: string): Promise<SuccessResponse> {
|
||||||
|
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/resume`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Passe au morceau suivant dans la queue
|
* Passe au morceau suivant dans la queue
|
||||||
* POST /api/control/renderers/{id}/next
|
* POST /api/control/renderers/{id}/next
|
||||||
@@ -203,6 +214,42 @@ class PMOControlAPI {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// QUEUE CONTENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lire du contenu immédiatement (clear queue + enqueue + play)
|
||||||
|
* POST /api/control/renderers/{id}/queue/play
|
||||||
|
*/
|
||||||
|
async playContent(
|
||||||
|
rendererId: string,
|
||||||
|
serverId: string,
|
||||||
|
objectId: string
|
||||||
|
): Promise<SuccessResponse> {
|
||||||
|
const payload: PlayContentRequest = { server_id: serverId, object_id: objectId }
|
||||||
|
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/queue/play`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajouter du contenu à la queue (sans démarrer la lecture)
|
||||||
|
* POST /api/control/renderers/{id}/queue/add
|
||||||
|
*/
|
||||||
|
async addToQueue(
|
||||||
|
rendererId: string,
|
||||||
|
serverId: string,
|
||||||
|
objectId: string
|
||||||
|
): Promise<SuccessResponse> {
|
||||||
|
const payload: PlayContentRequest = { server_id: serverId, object_id: objectId }
|
||||||
|
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/queue/add`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MEDIA SERVERS
|
// MEDIA SERVERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ export interface AttachPlaylistRequest {
|
|||||||
container_id: string
|
container_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlayContentRequest {
|
||||||
|
server_id: string
|
||||||
|
object_id: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SuccessResponse {
|
export interface SuccessResponse {
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
@@ -110,6 +115,7 @@ export type RendererEventPayload =
|
|||||||
| { type: 'mute_changed'; renderer_id: string; mute: boolean; timestamp: string }
|
| { type: 'mute_changed'; renderer_id: string; mute: boolean; timestamp: string }
|
||||||
| { type: 'metadata_changed'; renderer_id: string; title: string | null; artist: string | null; album: string | null; album_art_uri: string | null; timestamp: string }
|
| { type: 'metadata_changed'; renderer_id: string; title: string | null; artist: string | null; album: string | null; album_art_uri: string | null; timestamp: string }
|
||||||
| { type: 'queue_updated'; renderer_id: string; queue_length: number; timestamp: string }
|
| { type: 'queue_updated'; renderer_id: string; queue_length: number; timestamp: string }
|
||||||
|
| { type: 'binding_changed'; renderer_id: string; server_id: string | null; container_id: string | null; timestamp: string }
|
||||||
|
|
||||||
export type MediaServerEventPayload =
|
export type MediaServerEventPayload =
|
||||||
| { type: 'global_updated'; server_id: string; system_update_id: number | null; timestamp: string }
|
| { type: 'global_updated'; server_id: string; system_update_id: number | null; timestamp: string }
|
||||||
|
|||||||
@@ -1,42 +1,27 @@
|
|||||||
// Store Pinia pour le playback (métadonnées et positions)
|
// Store Pinia pour le playback (métadonnées)
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { TrackMetadata, PositionInfo, RendererEventPayload } from '../services/pmocontrol/types'
|
import type { TrackMetadata, RendererEventPayload } from '../services/pmocontrol/types'
|
||||||
|
|
||||||
export const usePlaybackStore = defineStore('playback', () => {
|
export const usePlaybackStore = defineStore('playback', () => {
|
||||||
// État
|
// État
|
||||||
// Map de renderer_id → métadonnées de la piste courante
|
// Map de renderer_id → métadonnées de la piste courante
|
||||||
const currentTracks = ref<Map<string, TrackMetadata>>(new Map())
|
const currentTracks = ref<Map<string, TrackMetadata>>(new Map())
|
||||||
|
|
||||||
// Map de renderer_id → position actuelle
|
|
||||||
const positions = ref<Map<string, PositionInfo>>(new Map())
|
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
const getTrackMetadata = (rendererId: string) => {
|
const getTrackMetadata = (rendererId: string) => {
|
||||||
return currentTracks.value.get(rendererId)
|
return currentTracks.value.get(rendererId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPosition = (rendererId: string) => {
|
|
||||||
return positions.value.get(rendererId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
function updateMetadata(rendererId: string, metadata: TrackMetadata) {
|
function updateMetadata(rendererId: string, metadata: TrackMetadata) {
|
||||||
currentTracks.value.set(rendererId, metadata)
|
currentTracks.value.set(rendererId, metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePosition(rendererId: string, position: PositionInfo) {
|
|
||||||
positions.value.set(rendererId, position)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearMetadata(rendererId: string) {
|
function clearMetadata(rendererId: string) {
|
||||||
currentTracks.value.delete(rendererId)
|
currentTracks.value.delete(rendererId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPosition(rendererId: string) {
|
|
||||||
positions.value.delete(rendererId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE event handling
|
// SSE event handling
|
||||||
function updateFromSSE(event: RendererEventPayload) {
|
function updateFromSSE(event: RendererEventPayload) {
|
||||||
const rendererId = event.renderer_id
|
const rendererId = event.renderer_id
|
||||||
@@ -54,21 +39,10 @@ export const usePlaybackStore = defineStore('playback', () => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'position_changed': {
|
|
||||||
const position: PositionInfo = {
|
|
||||||
track: event.track,
|
|
||||||
rel_time: event.rel_time,
|
|
||||||
track_duration: event.track_duration,
|
|
||||||
}
|
|
||||||
updatePosition(rendererId, position)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'state_changed': {
|
case 'state_changed': {
|
||||||
// Si stopped, on peut clear les métadonnées
|
// Si stopped, on peut clear les métadonnées
|
||||||
if (event.state === 'STOPPED' || event.state === 'NO_MEDIA') {
|
if (event.state === 'STOPPED' || event.state === 'NO_MEDIA') {
|
||||||
clearMetadata(rendererId)
|
clearMetadata(rendererId)
|
||||||
clearPosition(rendererId)
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -78,15 +52,11 @@ export const usePlaybackStore = defineStore('playback', () => {
|
|||||||
return {
|
return {
|
||||||
// État
|
// État
|
||||||
currentTracks,
|
currentTracks,
|
||||||
positions,
|
|
||||||
// Getters
|
// Getters
|
||||||
getTrackMetadata,
|
getTrackMetadata,
|
||||||
getPosition,
|
|
||||||
// Actions
|
// Actions
|
||||||
updateMetadata,
|
updateMetadata,
|
||||||
updatePosition,
|
|
||||||
clearMetadata,
|
clearMetadata,
|
||||||
clearPosition,
|
|
||||||
updateFromSSE,
|
updateFromSSE,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -103,6 +103,35 @@ export const useRenderersStore = defineStore('renderers', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
async function pause(id: string) {
|
||||||
try {
|
try {
|
||||||
await api.pause(id)
|
await api.pause(id)
|
||||||
@@ -123,6 +152,16 @@ export const useRenderersStore = defineStore('renderers', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async function next(id: string) {
|
||||||
try {
|
try {
|
||||||
await api.next(id)
|
await api.next(id)
|
||||||
@@ -198,6 +237,58 @@ export const useRenderersStore = defineStore('renderers', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
// SSE event handling
|
||||||
function updateFromSSE(event: RendererEventPayload) {
|
function updateFromSSE(event: RendererEventPayload) {
|
||||||
const rendererId = event.renderer_id
|
const rendererId = event.renderer_id
|
||||||
@@ -251,6 +342,23 @@ export const useRenderersStore = defineStore('renderers', () => {
|
|||||||
// Les métadonnées sont gérées par le store playback
|
// Les métadonnées sont gérées par le store playback
|
||||||
break
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,8 +395,10 @@ export const useRenderersStore = defineStore('renderers', () => {
|
|||||||
fetchQueue,
|
fetchQueue,
|
||||||
fetchBinding,
|
fetchBinding,
|
||||||
play,
|
play,
|
||||||
|
resumeOrPlayFromQueue,
|
||||||
pause,
|
pause,
|
||||||
stop,
|
stop,
|
||||||
|
resume,
|
||||||
next,
|
next,
|
||||||
setVolume,
|
setVolume,
|
||||||
volumeUp,
|
volumeUp,
|
||||||
@@ -296,6 +406,9 @@ export const useRenderersStore = defineStore('renderers', () => {
|
|||||||
toggleMute,
|
toggleMute,
|
||||||
attachPlaylist,
|
attachPlaylist,
|
||||||
detachPlaylist,
|
detachPlaylist,
|
||||||
|
attachAndPlayPlaylist,
|
||||||
|
playContent,
|
||||||
|
addToQueue,
|
||||||
updateFromSSE,
|
updateFromSSE,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
|
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'
|
||||||
import VolumeControl from '@/components/pmocontrol/VolumeControl.vue'
|
import VolumeControl from '@/components/pmocontrol/VolumeControl.vue'
|
||||||
@@ -13,6 +14,7 @@ import { ArrowLeft, Radio } from 'lucide-vue-next'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const renderersStore = useRenderersStore()
|
const renderersStore = useRenderersStore()
|
||||||
|
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 = computed(() => renderersStore.getRendererById(rendererId.value))
|
||||||
@@ -20,6 +22,9 @@ const state = computed(() => renderersStore.getStateById(rendererId.value))
|
|||||||
|
|
||||||
// Charger les données au montage si nécessaire
|
// Charger les données au montage si nécessaire
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Indiquer à uiStore quel renderer est sélectionné
|
||||||
|
uiStore.selectRenderer(rendererId.value)
|
||||||
|
|
||||||
if (!renderer.value) {
|
if (!renderer.value) {
|
||||||
await renderersStore.fetchRenderers()
|
await renderersStore.fetchRenderers()
|
||||||
}
|
}
|
||||||
@@ -29,6 +34,11 @@ onMounted(async () => {
|
|||||||
await renderersStore.fetchQueue(rendererId.value)
|
await renderersStore.fetchQueue(rendererId.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Nettoyer la sélection au démontage
|
||||||
|
onUnmounted(() => {
|
||||||
|
uiStore.selectRenderer(null)
|
||||||
|
})
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.push('/')
|
router.push('/')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
println!("Lecture de: {}", file_path);
|
println!("Lecture de: {}", file_path);
|
||||||
|
|
||||||
// Créer la source audio (lit le fichier FLAC)
|
// Créer la source audio (lit le fichier FLAC)
|
||||||
let mut source = FileSource::new(file_path).await?;
|
let mut source = FileSource::new(file_path);
|
||||||
|
|
||||||
// Créer le sink audio (joue sur la sortie audio)
|
// Créer le sink audio (joue sur la sortie audio)
|
||||||
let sink = AudioSink::new();
|
let sink = AudioSink::new();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let test_url = "https://fr.getsamplefiles.com/download/mp3/sample-3.mp3";
|
let test_url = "https://fr.getsamplefiles.com/download/mp3/sample-3.mp3";
|
||||||
|
|
||||||
println!("\nDownloading: {}", test_url);
|
println!("\nDownloading: {}", test_url);
|
||||||
let pk = cache::add_with_metadata_extraction(&cache, test_url, Some("test")).await?;
|
let pk = cache::add_with_metadata_extraction(cache, test_url, Some("test")).await?;
|
||||||
|
|
||||||
println!("\nPK: {}", pk);
|
println!("\nPK: {}", pk);
|
||||||
let file_path = cache.get_file_path(&pk);
|
let file_path = cache.get_file_path(&pk);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ axum = { version = "0.8.4", optional = true }
|
|||||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||||
serde_json = { version = "1.0", optional = true }
|
serde_json = { version = "1.0", optional = true }
|
||||||
tokio = { version = "1", features = ["sync", "rt"], optional = true }
|
tokio = { version = "1", features = ["sync", "rt"], optional = true }
|
||||||
|
tokio-util = { version = "0.7", optional = true }
|
||||||
async-trait = { version = "0.1", optional = true }
|
async-trait = { version = "0.1", optional = true }
|
||||||
tokio-stream = { version = "0.1", features = ["sync"], optional = true }
|
tokio-stream = { version = "0.1", features = ["sync"], optional = true }
|
||||||
async-stream = { version = "0.3", optional = true }
|
async-stream = { version = "0.3", optional = true }
|
||||||
@@ -38,4 +39,4 @@ percent-encoding = "2.3"
|
|||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
# Active l'API REST pmoserver
|
# Active l'API REST pmoserver
|
||||||
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:serde", "dep:serde_json", "dep:tokio", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"]
|
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:serde", "dep:serde_json", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"]
|
||||||
|
|||||||
@@ -35,18 +35,18 @@ use crate::upnp_renderer::UpnpRenderer;
|
|||||||
/// refresh it whenever the server notifies us of changes to that container.
|
/// refresh it whenever the server notifies us of changes to that container.
|
||||||
/// User-driven mutations (clear, enqueue, etc.) break the binding automatically.
|
/// User-driven mutations (clear, enqueue, etc.) break the binding automatically.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct PlaylistBinding {
|
pub struct PlaylistBinding {
|
||||||
/// MediaServer that owns the playlist container.
|
/// MediaServer that owns the playlist container.
|
||||||
server_id: ServerId,
|
pub server_id: ServerId,
|
||||||
/// DIDL-Lite object id of the playlist container.
|
/// DIDL-Lite object id of the playlist container.
|
||||||
container_id: String,
|
pub container_id: String,
|
||||||
/// True once at least one ContainerUpdateIDs notification has been seen.
|
/// True once at least one ContainerUpdateIDs notification has been seen.
|
||||||
has_seen_update: bool,
|
pub(crate) has_seen_update: bool,
|
||||||
/// Flag used internally to signal that the queue should be refreshed
|
/// Flag used internally to signal that the queue should be refreshed
|
||||||
/// from the server container.
|
/// from the server container.
|
||||||
pending_refresh: bool,
|
pub(crate) pending_refresh: bool,
|
||||||
/// Whether the next refresh should auto-start playback if the renderer is idle.
|
/// Whether the next refresh should auto-start playback if the renderer is idle.
|
||||||
auto_play_on_refresh: bool,
|
pub(crate) auto_play_on_refresh: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Control point minimal :
|
/// Control point minimal :
|
||||||
@@ -177,18 +177,33 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract and emit metadata changes
|
// Extract and emit metadata changes
|
||||||
if let Some(metadata) = extract_track_metadata(&position) {
|
match extract_track_metadata(&position) {
|
||||||
let metadata_changed = match prev_snapshot.last_metadata.as_ref() {
|
Some(metadata) => {
|
||||||
Some(prev) => prev != &metadata,
|
let metadata_changed = match prev_snapshot.last_metadata.as_ref() {
|
||||||
None => true,
|
Some(prev) => prev != &metadata,
|
||||||
};
|
None => true,
|
||||||
|
};
|
||||||
|
|
||||||
if metadata_changed {
|
if metadata_changed {
|
||||||
runtime_cp.emit_renderer_event(RendererEvent::MetadataChanged {
|
debug!(
|
||||||
id: renderer_id.clone(),
|
renderer = renderer_id.0.as_str(),
|
||||||
metadata: metadata.clone(),
|
title = metadata.title.as_deref(),
|
||||||
});
|
artist = metadata.artist.as_deref(),
|
||||||
new_snapshot.last_metadata = Some(metadata);
|
"Emitting metadata changed event"
|
||||||
|
);
|
||||||
|
runtime_cp.emit_renderer_event(RendererEvent::MetadataChanged {
|
||||||
|
id: renderer_id.clone(),
|
||||||
|
metadata: metadata.clone(),
|
||||||
|
});
|
||||||
|
new_snapshot.last_metadata = Some(metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
debug!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
has_track_metadata = position.track_metadata.is_some(),
|
||||||
|
"No metadata extracted from position info"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,12 +611,13 @@ impl ControlPoint {
|
|||||||
renderer_id: &RendererId,
|
renderer_id: &RendererId,
|
||||||
) -> anyhow::Result<(Vec<PlaybackItem>, Option<usize>)> {
|
) -> anyhow::Result<(Vec<PlaybackItem>, Option<usize>)> {
|
||||||
if !self.runtime.has_entry(renderer_id) {
|
if !self.runtime.has_entry(renderer_id) {
|
||||||
let err = Self::runtime_entry_missing(renderer_id);
|
// Renderer not yet initialized in runtime (just discovered via SSDP)
|
||||||
warn!(
|
// This is normal and will be fixed on first polling cycle
|
||||||
|
debug!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
"Cannot snapshot full queue: renderer not registered in runtime"
|
"Renderer not yet initialized in runtime, returning empty queue"
|
||||||
);
|
);
|
||||||
return Err(err);
|
return Err(Self::runtime_entry_missing(renderer_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.runtime
|
self.runtime
|
||||||
@@ -609,6 +625,84 @@ impl ControlPoint {
|
|||||||
.ok_or_else(|| Self::runtime_entry_missing(renderer_id))
|
.ok_or_else(|| Self::runtime_entry_missing(renderer_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play the current item from the queue without advancing the index.
|
||||||
|
///
|
||||||
|
/// This is useful after a Stop operation to resume playback from the current
|
||||||
|
/// position rather than skipping to the next track.
|
||||||
|
pub fn play_current_from_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
||||||
|
if !self.runtime.has_entry(renderer_id) {
|
||||||
|
let err = Self::runtime_entry_missing(renderer_id);
|
||||||
|
warn!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
"Cannot play current: renderer not registered in runtime"
|
||||||
|
);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((item, remaining)) = self.runtime.peek_current(renderer_id) else {
|
||||||
|
debug!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
"play_current_from_queue: queue is empty or no current item"
|
||||||
|
);
|
||||||
|
self.runtime
|
||||||
|
.set_playback_source(renderer_id, PlaybackSource::None);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
queue_len = remaining + 1,
|
||||||
|
uri = item.uri.as_str(),
|
||||||
|
"Playing current playback item from queue"
|
||||||
|
);
|
||||||
|
|
||||||
|
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
|
||||||
|
warn!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
"Renderer disappeared before queue playback could start"
|
||||||
|
);
|
||||||
|
anyhow!("Renderer {} not found", renderer_id.0)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if matches!(renderer.info().protocol, RendererProtocol::OpenHomeOnly) {
|
||||||
|
self.runtime
|
||||||
|
.set_playback_source(renderer_id, PlaybackSource::None);
|
||||||
|
return Err(op_not_supported(
|
||||||
|
"play_current_from_queue",
|
||||||
|
"OpenHomeOnly renderer",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let playback = (|| -> anyhow::Result<()> {
|
||||||
|
let didl_metadata = item.to_didl_metadata();
|
||||||
|
renderer.play_uri(&item.uri, &didl_metadata)?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
match playback {
|
||||||
|
Ok(()) => {
|
||||||
|
info!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
uri = item.uri.as_str(),
|
||||||
|
"Queue playback started (current item)"
|
||||||
|
);
|
||||||
|
self.runtime
|
||||||
|
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
error = %e,
|
||||||
|
"Failed to play current item from queue"
|
||||||
|
);
|
||||||
|
self.runtime
|
||||||
|
.set_playback_source(renderer_id, PlaybackSource::None);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn play_next_from_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
pub fn play_next_from_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
||||||
if !self.runtime.has_entry(renderer_id) {
|
if !self.runtime.has_entry(renderer_id) {
|
||||||
let err = Self::runtime_entry_missing(renderer_id);
|
let err = Self::runtime_entry_missing(renderer_id);
|
||||||
@@ -659,7 +753,8 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let playback = (|| -> anyhow::Result<()> {
|
let playback = (|| -> anyhow::Result<()> {
|
||||||
renderer.play_uri(&item.uri, "")?;
|
let didl_metadata = item.to_didl_metadata();
|
||||||
|
renderer.play_uri(&item.uri, &didl_metadata)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -697,7 +792,8 @@ impl ControlPoint {
|
|||||||
if let Some(upnp) = renderer.as_upnp() {
|
if let Some(upnp) = renderer.as_upnp() {
|
||||||
let known_supported = upnp.supports_set_next();
|
let known_supported = upnp.supports_set_next();
|
||||||
if known_supported || upnp.has_avtransport() {
|
if known_supported || upnp.has_avtransport() {
|
||||||
match upnp.set_next_uri(&next_item.uri, "") {
|
let next_didl_metadata = next_item.to_didl_metadata();
|
||||||
|
match upnp.set_next_uri(&next_item.uri, &next_didl_metadata) {
|
||||||
Ok(_) => debug!(
|
Ok(_) => debug!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
"Prefetched next track via SetNextAVTransportURI"
|
"Prefetched next track via SetNextAVTransportURI"
|
||||||
@@ -745,6 +841,32 @@ impl ControlPoint {
|
|||||||
self.play_next_from_queue(renderer_id)
|
self.play_next_from_queue(renderer_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop playback in response to user action (e.g., Stop button in UI).
|
||||||
|
///
|
||||||
|
/// This method marks the stop as user-requested to prevent automatic
|
||||||
|
/// advancement to the next track in the queue when the STOPPED event
|
||||||
|
/// is received from the renderer.
|
||||||
|
pub fn user_stop(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
||||||
|
// Mark that user requested stop before actually stopping
|
||||||
|
self.runtime.mark_user_stop_requested(renderer_id);
|
||||||
|
|
||||||
|
// Get renderer and call stop
|
||||||
|
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
|
||||||
|
warn!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
"Cannot stop: renderer not found in registry"
|
||||||
|
);
|
||||||
|
anyhow!("Renderer {} not found", renderer_id.0)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
"User-requested stop"
|
||||||
|
);
|
||||||
|
|
||||||
|
renderer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
/// Subscribe to renderer events emitted by the control point runtime.
|
/// Subscribe to renderer events emitted by the control point runtime.
|
||||||
///
|
///
|
||||||
/// Each subscriber receives all future events independently.
|
/// Each subscriber receives all future events independently.
|
||||||
@@ -768,48 +890,83 @@ impl ControlPoint {
|
|||||||
/// container whenever the server notifies us of changes via ContentDirectory
|
/// container whenever the server notifies us of changes via ContentDirectory
|
||||||
/// events. The binding is broken if the user explicitly mutates the queue
|
/// events. The binding is broken if the user explicitly mutates the queue
|
||||||
/// through methods like `clear_queue` or `enqueue_items`.
|
/// through methods like `clear_queue` or `enqueue_items`.
|
||||||
|
/// Attach a renderer's queue to a playlist container.
|
||||||
|
///
|
||||||
|
/// The queue will be automatically refreshed when the playlist changes on the server.
|
||||||
pub fn attach_queue_to_playlist(
|
pub fn attach_queue_to_playlist(
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &RendererId,
|
||||||
server_id: ServerId,
|
server_id: ServerId,
|
||||||
container_id: String,
|
container_id: String,
|
||||||
) {
|
) {
|
||||||
|
self.attach_queue_to_playlist_internal(renderer_id, server_id, container_id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach a renderer's queue to a playlist container without doing the initial refresh.
|
||||||
|
///
|
||||||
|
/// This is useful when the queue has already been manually populated and we just want
|
||||||
|
/// to track future changes to the playlist.
|
||||||
|
pub fn attach_queue_to_playlist_without_refresh(
|
||||||
|
&self,
|
||||||
|
renderer_id: &RendererId,
|
||||||
|
server_id: ServerId,
|
||||||
|
container_id: String,
|
||||||
|
) {
|
||||||
|
self.attach_queue_to_playlist_internal(renderer_id, server_id, container_id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal implementation with optional skip of initial refresh
|
||||||
|
fn attach_queue_to_playlist_internal(
|
||||||
|
&self,
|
||||||
|
renderer_id: &RendererId,
|
||||||
|
server_id: ServerId,
|
||||||
|
container_id: String,
|
||||||
|
skip_initial_refresh: bool,
|
||||||
|
) {
|
||||||
|
let binding = PlaylistBinding {
|
||||||
|
server_id: server_id.clone(),
|
||||||
|
container_id: container_id.clone(),
|
||||||
|
has_seen_update: false,
|
||||||
|
pending_refresh: !skip_initial_refresh,
|
||||||
|
auto_play_on_refresh: !skip_initial_refresh,
|
||||||
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut bindings = self.playlist_bindings.lock().unwrap();
|
let mut bindings = self.playlist_bindings.lock().unwrap();
|
||||||
bindings.insert(
|
bindings.insert(renderer_id.clone(), binding.clone());
|
||||||
renderer_id.clone(),
|
|
||||||
PlaylistBinding {
|
|
||||||
server_id: server_id.clone(),
|
|
||||||
container_id: container_id.clone(),
|
|
||||||
has_seen_update: false,
|
|
||||||
pending_refresh: true,
|
|
||||||
auto_play_on_refresh: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
info!(
|
info!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
server = server_id.0.as_str(),
|
server = server_id.0.as_str(),
|
||||||
container = container_id.as_str(),
|
container = container_id.as_str(),
|
||||||
|
skip_refresh = skip_initial_refresh,
|
||||||
"Queue attached to playlist container"
|
"Queue attached to playlist container"
|
||||||
);
|
);
|
||||||
} // Drop bindings lock here before calling refresh_attached_queue_for
|
} // Drop bindings lock here before calling refresh_attached_queue_for
|
||||||
|
|
||||||
let mut auto_start_cb = |rid: &RendererId| self.start_queue_playback_if_idle(rid);
|
// Emit binding changed event to notify frontend
|
||||||
if let Err(err) = refresh_attached_queue_for(
|
self.emit_renderer_event(RendererEvent::BindingChanged {
|
||||||
&self.registry,
|
id: renderer_id.clone(),
|
||||||
&self.runtime,
|
binding: Some(binding),
|
||||||
&self.playlist_bindings,
|
});
|
||||||
renderer_id,
|
|
||||||
&self.event_bus,
|
if !skip_initial_refresh {
|
||||||
Some(&mut auto_start_cb),
|
let mut auto_start_cb = |rid: &RendererId| self.start_queue_playback_if_idle(rid);
|
||||||
) {
|
if let Err(err) = refresh_attached_queue_for(
|
||||||
warn!(
|
&self.registry,
|
||||||
renderer = renderer_id.0.as_str(),
|
&self.runtime,
|
||||||
server = server_id.0.as_str(),
|
&self.playlist_bindings,
|
||||||
container = container_id.as_str(),
|
renderer_id,
|
||||||
error = %err,
|
&self.event_bus,
|
||||||
"Initial playlist refresh after attachment failed"
|
Some(&mut auto_start_cb),
|
||||||
);
|
) {
|
||||||
|
warn!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
server = server_id.0.as_str(),
|
||||||
|
container = container_id.as_str(),
|
||||||
|
error = %err,
|
||||||
|
"Initial playlist refresh after attachment failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,14 +975,23 @@ impl ControlPoint {
|
|||||||
/// After calling this, the queue will no longer be automatically refreshed
|
/// After calling this, the queue will no longer be automatically refreshed
|
||||||
/// from the server. If no binding existed, this is a no-op.
|
/// from the server. If no binding existed, this is a no-op.
|
||||||
pub fn detach_queue_playlist(&self, renderer_id: &RendererId) {
|
pub fn detach_queue_playlist(&self, renderer_id: &RendererId) {
|
||||||
let mut bindings = self.playlist_bindings.lock().unwrap();
|
let removed = {
|
||||||
if let Some(binding) = bindings.remove(renderer_id) {
|
let mut bindings = self.playlist_bindings.lock().unwrap();
|
||||||
|
bindings.remove(renderer_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(binding) = removed {
|
||||||
info!(
|
info!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
server = binding.server_id.0.as_str(),
|
server = binding.server_id.0.as_str(),
|
||||||
container = binding.container_id.as_str(),
|
container = binding.container_id.as_str(),
|
||||||
"Queue detached from playlist container"
|
"Queue detached from playlist container"
|
||||||
);
|
);
|
||||||
|
// Emit binding changed event to notify frontend
|
||||||
|
self.emit_renderer_event(RendererEvent::BindingChanged {
|
||||||
|
id: renderer_id.clone(),
|
||||||
|
binding: None,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
debug!(
|
debug!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
@@ -878,7 +1044,14 @@ impl ControlPoint {
|
|||||||
if let RendererEvent::StateChanged { id, state } = event {
|
if let RendererEvent::StateChanged { id, state } = event {
|
||||||
match state {
|
match state {
|
||||||
PlaybackState::Stopped => {
|
PlaybackState::Stopped => {
|
||||||
if self.runtime.is_playing_from_queue(id) {
|
// Check if user requested stop (via Stop button in UI)
|
||||||
|
if self.runtime.check_and_clear_user_stop_requested(id) {
|
||||||
|
debug!(
|
||||||
|
renderer = id.0.as_str(),
|
||||||
|
"Renderer stopped by user request; not auto-advancing"
|
||||||
|
);
|
||||||
|
self.runtime.set_playback_source(id, PlaybackSource::None);
|
||||||
|
} else if self.runtime.is_playing_from_queue(id) {
|
||||||
debug!(
|
debug!(
|
||||||
renderer = id.0.as_str(),
|
renderer = id.0.as_str(),
|
||||||
"Renderer stopped after queue-driven playback; advancing"
|
"Renderer stopped after queue-driven playback; advancing"
|
||||||
@@ -933,6 +1106,7 @@ struct RendererRuntimeEntry {
|
|||||||
snapshot: RendererRuntimeSnapshot,
|
snapshot: RendererRuntimeSnapshot,
|
||||||
queue: PlaybackQueue,
|
queue: PlaybackQueue,
|
||||||
playback_source: PlaybackSource,
|
playback_source: PlaybackSource,
|
||||||
|
user_stop_requested: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RuntimeState {
|
struct RuntimeState {
|
||||||
@@ -991,6 +1165,14 @@ impl RuntimeState {
|
|||||||
Some((item, remaining))
|
Some((item, remaining))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn peek_current(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> {
|
||||||
|
let entries = self.entries.lock().unwrap();
|
||||||
|
let entry = entries.get(id)?;
|
||||||
|
let item = entry.queue.peek()?.clone();
|
||||||
|
let remaining = entry.queue.upcoming_len();
|
||||||
|
Some((item, remaining))
|
||||||
|
}
|
||||||
|
|
||||||
fn set_playback_source(&self, id: &RendererId, source: PlaybackSource) {
|
fn set_playback_source(&self, id: &RendererId, source: PlaybackSource) {
|
||||||
let mut entries = self.entries.lock().unwrap();
|
let mut entries = self.entries.lock().unwrap();
|
||||||
if let Some(entry) = entries.get_mut(id) {
|
if let Some(entry) = entries.get_mut(id) {
|
||||||
@@ -1029,6 +1211,24 @@ impl RuntimeState {
|
|||||||
.or_insert_with(RendererRuntimeEntry::default);
|
.or_insert_with(RendererRuntimeEntry::default);
|
||||||
f(entry)
|
f(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mark_user_stop_requested(&self, id: &RendererId) {
|
||||||
|
let mut entries = self.entries.lock().unwrap();
|
||||||
|
if let Some(entry) = entries.get_mut(id) {
|
||||||
|
entry.user_stop_requested = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_and_clear_user_stop_requested(&self, id: &RendererId) -> bool {
|
||||||
|
let mut entries = self.entries.lock().unwrap();
|
||||||
|
if let Some(entry) = entries.get_mut(id) {
|
||||||
|
let was_requested = entry.user_stop_requested;
|
||||||
|
entry.user_stop_requested = false;
|
||||||
|
was_requested
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal helper to refresh a renderer's playback queue from its bound
|
/// Internal helper to refresh a renderer's playback queue from its bound
|
||||||
@@ -1402,7 +1602,13 @@ fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInfo) -
|
|||||||
|
|
||||||
/// Extract TrackMetadata from DIDL-Lite XML in PlaybackPositionInfo.
|
/// Extract TrackMetadata from DIDL-Lite XML in PlaybackPositionInfo.
|
||||||
fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option<TrackMetadata> {
|
fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option<TrackMetadata> {
|
||||||
let didl_xml = position.track_metadata.as_ref()?;
|
let didl_xml = match position.track_metadata.as_ref() {
|
||||||
|
Some(xml) => xml,
|
||||||
|
None => {
|
||||||
|
debug!("Position info has no track_metadata (DIDL-Lite XML)");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Parse DIDL-Lite XML
|
// Parse DIDL-Lite XML
|
||||||
let didl = match pmodidl::parse_metadata::<pmodidl::DIDLLite>(didl_xml) {
|
let didl = match pmodidl::parse_metadata::<pmodidl::DIDLLite>(didl_xml) {
|
||||||
@@ -1414,7 +1620,13 @@ fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option<TrackMetada
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Extract first item metadata
|
// Extract first item metadata
|
||||||
let item = didl.items.first()?;
|
let item = match didl.items.first() {
|
||||||
|
Some(item) => item,
|
||||||
|
None => {
|
||||||
|
debug!("DIDL-Lite has no items");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Some(TrackMetadata {
|
Some(TrackMetadata {
|
||||||
title: Some(item.title.clone()),
|
title: Some(item.title.clone()),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ pub use capabilities::{
|
|||||||
VolumeControl,
|
VolumeControl,
|
||||||
};
|
};
|
||||||
pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
||||||
pub use control_point::ControlPoint;
|
pub use control_point::{ControlPoint, PlaylistBinding};
|
||||||
pub use linkplay::LinkPlayRenderer;
|
pub use linkplay::LinkPlayRenderer;
|
||||||
pub use media_server::{
|
pub use media_server::{
|
||||||
MediaBrowser, MediaEntry, MediaResource, MediaServerInfo, MusicServer, ServerId,
|
MediaBrowser, MediaEntry, MediaResource, MediaServerInfo, MusicServer, ServerId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::capabilities::{PlaybackPositionInfo, PlaybackState};
|
use crate::capabilities::{PlaybackPositionInfo, PlaybackState};
|
||||||
|
use crate::control_point::PlaylistBinding;
|
||||||
use crate::media_server::ServerId;
|
use crate::media_server::ServerId;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
@@ -99,6 +100,10 @@ pub enum RendererEvent {
|
|||||||
id: RendererId,
|
id: RendererId,
|
||||||
queue_length: usize,
|
queue_length: usize,
|
||||||
},
|
},
|
||||||
|
BindingChanged {
|
||||||
|
id: RendererId,
|
||||||
|
binding: Option<PlaylistBinding>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|||||||
@@ -172,6 +172,16 @@ pub struct AttachPlaylistRequest {
|
|||||||
pub container_id: String,
|
pub container_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Requête pour lire ou ajouter du contenu à la queue
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||||
|
pub struct PlayContentRequest {
|
||||||
|
/// ID du serveur de médias
|
||||||
|
pub server_id: String,
|
||||||
|
/// ID de l'objet (container ou item)
|
||||||
|
pub object_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Réponse générique de succès
|
/// Réponse générique de succès
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
@@ -280,6 +290,8 @@ GET /control/servers/{server_id}/containers/{container_id}
|
|||||||
crate::pmoserver_ext::toggle_mute_renderer,
|
crate::pmoserver_ext::toggle_mute_renderer,
|
||||||
crate::pmoserver_ext::attach_playlist_binding,
|
crate::pmoserver_ext::attach_playlist_binding,
|
||||||
crate::pmoserver_ext::detach_playlist_binding,
|
crate::pmoserver_ext::detach_playlist_binding,
|
||||||
|
crate::pmoserver_ext::play_content,
|
||||||
|
crate::pmoserver_ext::add_to_queue,
|
||||||
crate::pmoserver_ext::list_servers,
|
crate::pmoserver_ext::list_servers,
|
||||||
crate::pmoserver_ext::browse_container,
|
crate::pmoserver_ext::browse_container,
|
||||||
crate::sse::all_events_sse,
|
crate::sse::all_events_sse,
|
||||||
@@ -297,6 +309,7 @@ GET /control/servers/{server_id}/containers/{container_id}
|
|||||||
BrowseResponse,
|
BrowseResponse,
|
||||||
VolumeSetRequest,
|
VolumeSetRequest,
|
||||||
AttachPlaylistRequest,
|
AttachPlaylistRequest,
|
||||||
|
PlayContentRequest,
|
||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -31,6 +31,67 @@ impl PlaybackItem {
|
|||||||
creator: None,
|
creator: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert PlaybackItem to DIDL-Lite XML metadata for SetAVTransportURI
|
||||||
|
pub fn to_didl_metadata(&self) -> String {
|
||||||
|
use quick_xml::escape::escape;
|
||||||
|
|
||||||
|
let title = self.title.as_deref().unwrap_or("Unknown");
|
||||||
|
let escaped_uri = escape(&self.uri);
|
||||||
|
let escaped_title = escape(title);
|
||||||
|
|
||||||
|
let mut didl = String::from(
|
||||||
|
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">"#,
|
||||||
|
);
|
||||||
|
didl.push_str(r#"<item id="0" parentID="-1" restricted="1">"#);
|
||||||
|
didl.push_str(&format!("<dc:title>{}</dc:title>", escaped_title));
|
||||||
|
|
||||||
|
if let Some(artist) = &self.artist {
|
||||||
|
let escaped_artist = escape(artist);
|
||||||
|
didl.push_str(&format!("<upnp:artist>{}</upnp:artist>", escaped_artist));
|
||||||
|
didl.push_str(&format!("<dc:creator>{}</dc:creator>", escaped_artist));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(album) = &self.album {
|
||||||
|
let escaped_album = escape(album);
|
||||||
|
didl.push_str(&format!("<upnp:album>{}</upnp:album>", escaped_album));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(genre) = &self.genre {
|
||||||
|
let escaped_genre = escape(genre);
|
||||||
|
didl.push_str(&format!("<upnp:genre>{}</upnp:genre>", escaped_genre));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(album_art) = &self.album_art_uri {
|
||||||
|
let escaped_art = escape(album_art);
|
||||||
|
didl.push_str(&format!("<upnp:albumArtURI>{}</upnp:albumArtURI>", escaped_art));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(date) = &self.date {
|
||||||
|
let escaped_date = escape(date);
|
||||||
|
didl.push_str(&format!("<dc:date>{}</dc:date>", escaped_date));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(track_num) = &self.track_number {
|
||||||
|
let escaped_track = escape(track_num);
|
||||||
|
didl.push_str(&format!(
|
||||||
|
"<upnp:originalTrackNumber>{}</upnp:originalTrackNumber>",
|
||||||
|
escaped_track
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add resource with URI
|
||||||
|
didl.push_str(&format!(
|
||||||
|
r#"<res protocolInfo="http-get:*:audio/*:*">{}</res>"#,
|
||||||
|
escaped_uri
|
||||||
|
));
|
||||||
|
|
||||||
|
didl.push_str(r#"<upnp:class>object.item.audioItem.musicTrack</upnp:class>"#);
|
||||||
|
didl.push_str("</item>");
|
||||||
|
didl.push_str("</DIDL-Lite>");
|
||||||
|
|
||||||
|
didl
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
|
|||||||
@@ -6,14 +6,16 @@
|
|||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::control_point::ControlPoint;
|
use crate::control_point::ControlPoint;
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::media_server::{MediaBrowser, MusicServer, ServerId};
|
use crate::media_server::{MediaBrowser, MediaEntry, MediaResource, MusicServer, ServerId};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::model::{RendererId, RendererProtocol};
|
use crate::model::{RendererId, RendererProtocol};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use crate::playback_queue::PlaybackItem;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::openapi::{
|
use crate::openapi::{
|
||||||
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
||||||
MediaServerSummary, QueueItem, QueueSnapshot, RendererState, RendererSummary, SuccessResponse,
|
MediaServerSummary, PlayContentRequest, QueueItem, QueueSnapshot, RendererState,
|
||||||
VolumeSetRequest,
|
RendererSummary, SuccessResponse, VolumeSetRequest,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl};
|
use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl};
|
||||||
@@ -260,18 +262,31 @@ async fn get_renderer_queue(
|
|||||||
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
|
||||||
let (items, current_index) =
|
// Verify renderer exists in registry
|
||||||
state
|
let _renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
.get_full_queue_snapshot(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
.map_err(|e| {
|
.ok_or_else(|| {
|
||||||
(
|
(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
Json(ErrorResponse {
|
Json(ErrorResponse {
|
||||||
error: format!("Failed to get queue: {}", e),
|
error: format!("Renderer {} not found", renderer_id),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Get queue snapshot - if renderer not yet in runtime (just discovered),
|
||||||
|
// return empty queue instead of error
|
||||||
|
let (items, current_index) = state
|
||||||
|
.control_point
|
||||||
|
.get_full_queue_snapshot(&rid)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
debug!(
|
||||||
|
renderer = renderer_id.as_str(),
|
||||||
|
"Renderer not yet initialized in runtime, returning empty queue"
|
||||||
|
);
|
||||||
|
(vec![], None)
|
||||||
|
});
|
||||||
|
|
||||||
let queue_items: Vec<QueueItem> = items
|
let queue_items: Vec<QueueItem> = items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -507,7 +522,8 @@ async fn stop_renderer(
|
|||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
|
||||||
let renderer = state
|
// Verify renderer exists
|
||||||
|
let _renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -519,8 +535,9 @@ async fn stop_renderer(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let renderer_clone = renderer.clone();
|
let control_point = state.control_point.clone();
|
||||||
let stop_task = tokio::task::spawn_blocking(move || renderer_clone.stop());
|
let rid_clone = rid.clone();
|
||||||
|
let stop_task = tokio::task::spawn_blocking(move || control_point.user_stop(&rid_clone));
|
||||||
|
|
||||||
time::timeout(TRANSPORT_COMMAND_TIMEOUT, stop_task)
|
time::timeout(TRANSPORT_COMMAND_TIMEOUT, stop_task)
|
||||||
.await
|
.await
|
||||||
@@ -563,6 +580,77 @@ async fn stop_renderer(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/resume - Reprend la lecture depuis le morceau actuel
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/resume",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Lecture reprise", body = SuccessResponse),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn resume_renderer(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
let control_point = state.control_point.clone();
|
||||||
|
let rid_clone = rid.clone();
|
||||||
|
|
||||||
|
let resume_task = tokio::task::spawn_blocking(move || {
|
||||||
|
control_point.play_current_from_queue(&rid_clone)
|
||||||
|
});
|
||||||
|
|
||||||
|
time::timeout(QUEUE_COMMAND_TIMEOUT, resume_task)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
warn!(
|
||||||
|
"Resume command for renderer {} exceeded {:?}",
|
||||||
|
renderer_id, QUEUE_COMMAND_TIMEOUT
|
||||||
|
);
|
||||||
|
(
|
||||||
|
StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!(
|
||||||
|
"Resume command timed out after {}s",
|
||||||
|
QUEUE_COMMAND_TIMEOUT.as_secs()
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Task join error during resume: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Internal task error: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!(
|
||||||
|
"Failed to resume renderer {}: {}",
|
||||||
|
renderer_id, e
|
||||||
|
);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to resume: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse {
|
||||||
|
message: "Playback resumed from current track".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// POST /control/renderers/{renderer_id}/next - Passe au morceau suivant de la queue
|
/// POST /control/renderers/{renderer_id}/next - Passe au morceau suivant de la queue
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -1077,6 +1165,238 @@ async fn detach_playlist_binding(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HANDLERS - QUEUE CONTENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/queue/play - Lire du contenu immédiatement
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/queue/play",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
request_body = PlayContentRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Contenu en cours de lecture", body = SuccessResponse),
|
||||||
|
(status = 404, description = "Renderer ou serveur non trouvé", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn play_content(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
Json(req): Json<PlayContentRequest>,
|
||||||
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
let sid = ServerId(req.server_id.clone());
|
||||||
|
let object_id = req.object_id.clone();
|
||||||
|
let object_id_for_log = object_id.clone();
|
||||||
|
|
||||||
|
// Get renderer to verify it exists
|
||||||
|
let renderer = state
|
||||||
|
.control_point
|
||||||
|
.music_renderer_by_id(&rid)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Renderer {} not found", renderer_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let control_point = Arc::clone(&state.control_point);
|
||||||
|
|
||||||
|
// Spawn blocking task for content loading
|
||||||
|
let play_task = tokio::task::spawn_blocking(move || {
|
||||||
|
// Fetch playback items from server
|
||||||
|
let items = fetch_playback_items(&control_point, &sid, &object_id)?;
|
||||||
|
|
||||||
|
if items.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("No playable content found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear queue
|
||||||
|
control_point.clear_queue(&rid)?;
|
||||||
|
|
||||||
|
// Enqueue items
|
||||||
|
control_point.enqueue_items(&rid, items.clone())?;
|
||||||
|
|
||||||
|
// Start playback
|
||||||
|
renderer.play()?;
|
||||||
|
|
||||||
|
// Auto-bind if playing a container (playlist/album)
|
||||||
|
// Rule: if multiple items, it's a container that should be bound
|
||||||
|
// We use _without_refresh because the queue was already populated by enqueue_items above
|
||||||
|
if items.len() > 1 {
|
||||||
|
debug!(
|
||||||
|
renderer = rid.0.as_str(),
|
||||||
|
server = sid.0.as_str(),
|
||||||
|
object = object_id.as_str(),
|
||||||
|
item_count = items.len(),
|
||||||
|
"Auto-binding playlist to renderer queue (without initial refresh)"
|
||||||
|
);
|
||||||
|
control_point.attach_queue_to_playlist_without_refresh(&rid, sid.clone(), object_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
});
|
||||||
|
|
||||||
|
time::timeout(QUEUE_COMMAND_TIMEOUT, play_task)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
warn!(
|
||||||
|
"Play content command for renderer {} exceeded {:?}",
|
||||||
|
renderer_id, QUEUE_COMMAND_TIMEOUT
|
||||||
|
);
|
||||||
|
(
|
||||||
|
StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!(
|
||||||
|
"Play content timed out after {}s",
|
||||||
|
QUEUE_COMMAND_TIMEOUT.as_secs()
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Task join error during play content: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Internal task error: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Failed to play content on renderer {}: {}", renderer_id, e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to play content: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
renderer = renderer_id.as_str(),
|
||||||
|
server = req.server_id.as_str(),
|
||||||
|
object = object_id_for_log.as_str(),
|
||||||
|
"Content playing via HTTP API"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse {
|
||||||
|
message: "Content playing".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/queue/add - Ajouter du contenu à la queue
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/queue/add",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
request_body = PlayContentRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Contenu ajouté à la queue", body = SuccessResponse),
|
||||||
|
(status = 404, description = "Renderer ou serveur non trouvé", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn add_to_queue(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
Json(req): Json<PlayContentRequest>,
|
||||||
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
let sid = ServerId(req.server_id.clone());
|
||||||
|
let object_id = req.object_id.clone();
|
||||||
|
let object_id_for_log = object_id.clone();
|
||||||
|
|
||||||
|
// Verify renderer exists
|
||||||
|
state
|
||||||
|
.control_point
|
||||||
|
.music_renderer_by_id(&rid)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Renderer {} not found", renderer_id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let control_point = Arc::clone(&state.control_point);
|
||||||
|
|
||||||
|
// Spawn blocking task for content loading
|
||||||
|
let add_task = tokio::task::spawn_blocking(move || {
|
||||||
|
// Fetch playback items from server
|
||||||
|
let items = fetch_playback_items(&control_point, &sid, &object_id)?;
|
||||||
|
|
||||||
|
if items.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("No playable content found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue items
|
||||||
|
control_point.enqueue_items(&rid, items)?;
|
||||||
|
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
});
|
||||||
|
|
||||||
|
time::timeout(QUEUE_COMMAND_TIMEOUT, add_task)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
warn!(
|
||||||
|
"Add to queue command for renderer {} exceeded {:?}",
|
||||||
|
renderer_id, QUEUE_COMMAND_TIMEOUT
|
||||||
|
);
|
||||||
|
(
|
||||||
|
StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!(
|
||||||
|
"Add to queue timed out after {}s",
|
||||||
|
QUEUE_COMMAND_TIMEOUT.as_secs()
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Task join error during add to queue: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Internal task error: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Failed to add content to queue for renderer {}: {}", renderer_id, e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to add to queue: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
renderer = renderer_id.as_str(),
|
||||||
|
server = req.server_id.as_str(),
|
||||||
|
object = object_id_for_log.as_str(),
|
||||||
|
"Content added to queue via HTTP API"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse {
|
||||||
|
message: "Content added to queue".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HANDLERS - MEDIA SERVERS
|
// HANDLERS - MEDIA SERVERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1236,6 +1556,93 @@ async fn browse_container(
|
|||||||
// HELPERS
|
// HELPERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Helper to fetch playback items from a media server object (container or item).
|
||||||
|
///
|
||||||
|
/// This function browses the server to get the entries and converts them to PlaybackItem.
|
||||||
|
/// For containers, it browses children. For items, it browses metadata.
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
fn fetch_playback_items(
|
||||||
|
control_point: &ControlPoint,
|
||||||
|
server_id: &ServerId,
|
||||||
|
object_id: &str,
|
||||||
|
) -> anyhow::Result<Vec<PlaybackItem>> {
|
||||||
|
// Get server info from registry
|
||||||
|
let server_info = control_point
|
||||||
|
.media_server(server_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Server {} not found", server_id.0))?;
|
||||||
|
|
||||||
|
if !server_info.online {
|
||||||
|
return Err(anyhow::anyhow!("Server {} is offline", server_id.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !server_info.has_content_directory {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Server {} does not support ContentDirectory",
|
||||||
|
server_id.0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create MusicServer
|
||||||
|
let music_server = MusicServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT)?;
|
||||||
|
|
||||||
|
// Browse the object to get entries
|
||||||
|
let entries = music_server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?;
|
||||||
|
|
||||||
|
// Convert to PlaybackItem
|
||||||
|
let items: Vec<PlaybackItem> = entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| playback_item_from_entry(&music_server, entry))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to convert a MediaEntry to a PlaybackItem.
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||||
|
// Ignore containers
|
||||||
|
if entry.is_container {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip "live stream" entries
|
||||||
|
if entry.title.to_ascii_lowercase().contains("live stream") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find an audio resource
|
||||||
|
let resource = entry.resources.iter().find(|res| is_audio_resource(res))?;
|
||||||
|
|
||||||
|
let mut item = PlaybackItem::new(resource.uri.clone());
|
||||||
|
item.title = Some(entry.title.clone());
|
||||||
|
item.server_id = Some(server.id().clone());
|
||||||
|
item.object_id = Some(entry.id.clone());
|
||||||
|
item.artist = entry.artist.clone();
|
||||||
|
item.album = entry.album.clone();
|
||||||
|
item.genre = entry.genre.clone();
|
||||||
|
item.album_art_uri = entry.album_art_uri.clone();
|
||||||
|
item.date = entry.date.clone();
|
||||||
|
item.track_number = entry.track_number.clone();
|
||||||
|
item.creator = entry.creator.clone();
|
||||||
|
|
||||||
|
Some(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to detect if a MediaResource is audio content.
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
fn is_audio_resource(res: &MediaResource) -> bool {
|
||||||
|
let lower = res.protocol_info.to_ascii_lowercase();
|
||||||
|
if lower.contains("audio/") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Check MIME type in protocolInfo (format: protocol:network:contentFormat:additionalInfo)
|
||||||
|
lower
|
||||||
|
.split(':')
|
||||||
|
.nth(2)
|
||||||
|
.map(|mime| mime.starts_with("audio/"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
fn protocol_to_string(protocol: &RendererProtocol) -> String {
|
fn protocol_to_string(protocol: &RendererProtocol) -> String {
|
||||||
match protocol {
|
match protocol {
|
||||||
@@ -1293,6 +1700,7 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
|||||||
.route("/renderers/{renderer_id}/play", post(play_renderer))
|
.route("/renderers/{renderer_id}/play", post(play_renderer))
|
||||||
.route("/renderers/{renderer_id}/pause", post(pause_renderer))
|
.route("/renderers/{renderer_id}/pause", post(pause_renderer))
|
||||||
.route("/renderers/{renderer_id}/stop", post(stop_renderer))
|
.route("/renderers/{renderer_id}/stop", post(stop_renderer))
|
||||||
|
.route("/renderers/{renderer_id}/resume", post(resume_renderer))
|
||||||
.route("/renderers/{renderer_id}/next", post(next_renderer))
|
.route("/renderers/{renderer_id}/next", post(next_renderer))
|
||||||
// Volume control
|
// Volume control
|
||||||
.route(
|
.route(
|
||||||
@@ -1320,6 +1728,15 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
|||||||
"/renderers/{renderer_id}/binding/detach",
|
"/renderers/{renderer_id}/binding/detach",
|
||||||
post(detach_playlist_binding),
|
post(detach_playlist_binding),
|
||||||
)
|
)
|
||||||
|
// Queue content
|
||||||
|
.route(
|
||||||
|
"/renderers/{renderer_id}/queue/play",
|
||||||
|
post(play_content),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/renderers/{renderer_id}/queue/add",
|
||||||
|
post(add_to_queue),
|
||||||
|
)
|
||||||
// Servers
|
// Servers
|
||||||
.route("/servers", get(list_servers))
|
.route("/servers", get(list_servers))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ pub enum RendererEventPayload {
|
|||||||
queue_length: usize,
|
queue_length: usize,
|
||||||
timestamp: chrono::DateTime<chrono::Utc>,
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
},
|
},
|
||||||
|
BindingChanged {
|
||||||
|
renderer_id: String,
|
||||||
|
server_id: Option<String>,
|
||||||
|
container_id: Option<String>,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Payload SSE pour un événement serveur de médias
|
/// Payload SSE pour un événement serveur de médias
|
||||||
@@ -189,6 +195,14 @@ pub async fn renderer_events_sse(
|
|||||||
timestamp,
|
timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
RendererEvent::BindingChanged { id, binding } => {
|
||||||
|
RendererEventPayload::BindingChanged {
|
||||||
|
renderer_id: id.0,
|
||||||
|
server_id: binding.as_ref().map(|b| b.server_id.0.clone()),
|
||||||
|
container_id: binding.as_ref().map(|b| b.container_id.clone()),
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Ok(json) = serde_json::to_string(&payload) {
|
if let Ok(json) = serde_json::to_string(&payload) {
|
||||||
@@ -355,6 +369,14 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
|
|||||||
timestamp,
|
timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
RendererEvent::BindingChanged { id, binding } => {
|
||||||
|
RendererEventPayload::BindingChanged {
|
||||||
|
renderer_id: id.0,
|
||||||
|
server_id: binding.as_ref().map(|b| b.server_id.0.clone()),
|
||||||
|
container_id: binding.as_ref().map(|b| b.container_id.clone()),
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let payload = UnifiedEventPayload::Renderer(renderer_payload);
|
let payload = UnifiedEventPayload::Renderer(renderer_payload);
|
||||||
|
|||||||
@@ -901,7 +901,7 @@ impl ParadiseChannelManager {
|
|||||||
history_builder: Option<ParadiseHistoryBuilder>,
|
history_builder: Option<ParadiseHistoryBuilder>,
|
||||||
server_base_url: Option<String>,
|
server_base_url: Option<String>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
tracing::warn!(
|
tracing::info!(
|
||||||
"➡️ Entering with_defaults_with_cover_cache ({} channels, base_url={:?})",
|
"➡️ Entering with_defaults_with_cover_cache ({} channels, base_url={:?})",
|
||||||
ALL_CHANNELS.len(),
|
ALL_CHANNELS.len(),
|
||||||
server_base_url
|
server_base_url
|
||||||
@@ -912,14 +912,14 @@ impl ParadiseChannelManager {
|
|||||||
config.server_base_url = server_base_url.clone();
|
config.server_base_url = server_base_url.clone();
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
tracing::warn!(
|
tracing::info!(
|
||||||
"⏳ Initializing Radio Paradise channel {} ({})...",
|
"⏳ Initializing Radio Paradise channel {} ({})...",
|
||||||
descriptor.display_name,
|
descriptor.display_name,
|
||||||
descriptor.slug
|
descriptor.slug
|
||||||
);
|
);
|
||||||
|
|
||||||
let history_opts = if let Some(builder) = &history_builder {
|
let history_opts = if let Some(builder) = &history_builder {
|
||||||
tracing::warn!(
|
tracing::debug!(
|
||||||
" ⏳ Building history options for channel {} ({})",
|
" ⏳ Building history options for channel {} ({})",
|
||||||
descriptor.display_name,
|
descriptor.display_name,
|
||||||
descriptor.slug
|
descriptor.slug
|
||||||
@@ -933,7 +933,7 @@ impl ParadiseChannelManager {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
tracing::warn!(
|
tracing::debug!(
|
||||||
" ⏩ History options ready for channel {} ({})",
|
" ⏩ History options ready for channel {} ({})",
|
||||||
descriptor.display_name,
|
descriptor.display_name,
|
||||||
descriptor.slug
|
descriptor.slug
|
||||||
@@ -945,7 +945,7 @@ impl ParadiseChannelManager {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(ch)) => {
|
Ok(Ok(ch)) => {
|
||||||
tracing::warn!(
|
tracing::info!(
|
||||||
"✅ Channel {} ({}) initialized in {:?}",
|
"✅ Channel {} ({}) initialized in {:?}",
|
||||||
descriptor.display_name,
|
descriptor.display_name,
|
||||||
descriptor.slug,
|
descriptor.slug,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ anyhow = "1.0"
|
|||||||
axum = "0.8.4"
|
axum = "0.8.4"
|
||||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
|
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
|
tokio-util = "0.7"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ use std::future::Future;
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::{signal, sync::RwLock, task::JoinHandle};
|
use tokio::{signal, sync::RwLock, task::JoinHandle};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
use utoipa_swagger_ui::SwaggerUi;
|
use utoipa_swagger_ui::SwaggerUi;
|
||||||
@@ -92,6 +93,7 @@ pub struct Server {
|
|||||||
join_handle: Option<JoinHandle<()>>,
|
join_handle: Option<JoinHandle<()>>,
|
||||||
log_state: Option<LogState>,
|
log_state: Option<LogState>,
|
||||||
api_registry: ApiRegistryState,
|
api_registry: ApiRegistryState,
|
||||||
|
shutdown_token: CancellationToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
@@ -126,6 +128,7 @@ impl Server {
|
|||||||
join_handle: None,
|
join_handle: None,
|
||||||
log_state: None,
|
log_state: None,
|
||||||
api_registry,
|
api_registry,
|
||||||
|
shutdown_token: CancellationToken::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +139,14 @@ impl Server {
|
|||||||
Self::new("PMO-Music-Server", url, port)
|
Self::new("PMO-Music-Server", url, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retourne une copie du token d'arrêt gracieux
|
||||||
|
///
|
||||||
|
/// Ce token peut être donné aux composants qui ont besoin de savoir
|
||||||
|
/// quand le serveur s'arrête (threads, tâches longues, etc.)
|
||||||
|
pub fn shutdown_token(&self) -> CancellationToken {
|
||||||
|
self.shutdown_token.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Ajoute une route JSON dynamique
|
/// Ajoute une route JSON dynamique
|
||||||
///
|
///
|
||||||
/// Crée un endpoint qui retourne du JSON. La closure fournie sera appelée
|
/// Crée un endpoint qui retourne du JSON. La closure fournie sera appelée
|
||||||
@@ -532,22 +543,43 @@ impl Server {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let router = self.router.clone();
|
let router = self.router.clone();
|
||||||
|
|
||||||
|
// Créer un channel pour signaler l'arrêt gracieux
|
||||||
|
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||||
|
|
||||||
|
// Tâche qui écoute Ctrl+C et signale l'arrêt
|
||||||
|
let shutdown_token_for_signal = self.shutdown_token.clone();
|
||||||
|
let shutdown_signal = tokio::spawn(async move {
|
||||||
|
signal::ctrl_c().await.expect("failed to listen for ctrl_c");
|
||||||
|
info!("Ctrl+C reçu, arrêt gracieux");
|
||||||
|
// Déclencher le token d'arrêt pour tous les composants
|
||||||
|
shutdown_token_for_signal.cancel();
|
||||||
|
// Envoyer le signal d'arrêt au serveur HTTP (ignorer l'erreur si le receiver a déjà été drop)
|
||||||
|
let _ = shutdown_tx.send(());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tâche serveur avec arrêt gracieux
|
||||||
let server_task = tokio::spawn(async move {
|
let server_task = tokio::spawn(async move {
|
||||||
let r = router.read().await.clone();
|
let r = router.read().await.clone();
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||||
axum::serve(listener, r.into_make_service()).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
let shutdown_task = tokio::spawn(async move {
|
// Serveur avec arrêt gracieux
|
||||||
signal::ctrl_c().await.expect("failed to listen for ctrl_c");
|
axum::serve(listener, r.into_make_service())
|
||||||
info!("Ctrl+C reçu, arrêt gracieux");
|
.with_graceful_shutdown(async move {
|
||||||
|
// Attendre le signal d'arrêt
|
||||||
|
let _ = shutdown_rx.await;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
info!("Serveur HTTP arrêté proprement");
|
||||||
});
|
});
|
||||||
|
|
||||||
self.join_handle = Some(tokio::spawn(async move {
|
self.join_handle = Some(tokio::spawn(async move {
|
||||||
tokio::select! {
|
// Attendre que le serveur se termine (après réception du signal)
|
||||||
_ = server_task => {},
|
let _ = server_task.await;
|
||||||
_ = shutdown_task => {},
|
// Nettoyer la tâche de signal
|
||||||
}
|
let _ = shutdown_signal.await;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user