Optimize UPnP discovery and server response handling
Refactor UPnP discovery to use a thread pool for fetching device descriptions, improving performance and responsiveness. Also, use spawn_blocking in server endpoints to prevent blocking the async runtime when performing sync operations. Additionally, add read timeout to SSDP socket and handle WouldBlock errors gracefully. Refactorisation du contrôle des renderers et amélioration de l'interface utilisateur Refactorisation complète du composant VolumeControl avec gestion d'erreur améliorée et debounce. Migration de l'interface de contrôle des renderers : - Suppression de l'ancienne barre d'onglets en bas - Intégration d'une nouvelle barre d'infos en bas avec les détails du renderer actif - Création d'un nouveau drawer pour la sélection des renderers - Déplacement des informations du renderer de l'onglet vers la barre d'infos Améliorations UI/UX : - Nouvelle interface de contrôle des renderers dans le drawer avec boutons de lecture/pause - Mise à jour des styles et animations pour une meilleure expérience utilisateur - Adaptation responsive pour les appareils mobiles
This commit is contained in:
@@ -1,52 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, toRef } from 'vue'
|
||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { Volume2, VolumeX } from 'lucide-vue-next'
|
||||
import { ref, watch, toRef } from "vue";
|
||||
import { useRenderer, useRenderers } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import { Volume2, VolumeX } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
rendererId: string;
|
||||
}>();
|
||||
|
||||
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||
const { setVolume, toggleMute } = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
const { state } = useRenderer(toRef(props, "rendererId"));
|
||||
const { setVolume, toggleMute } = useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const localVolume = ref(state.value?.volume ?? 50)
|
||||
const localVolume = ref(state.value?.volume ?? 50);
|
||||
|
||||
// Synchroniser localVolume avec le state
|
||||
watch(() => state.value?.volume, (newVolume) => {
|
||||
watch(
|
||||
() => state.value?.volume,
|
||||
(newVolume) => {
|
||||
if (newVolume !== undefined && newVolume !== null) {
|
||||
localVolume.value = newVolume
|
||||
localVolume.value = newVolume;
|
||||
}
|
||||
}, { immediate: true })
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Debounce pour le slider
|
||||
let debounceTimer: number | null = null
|
||||
let debounceTimer: number | null = null;
|
||||
function handleVolumeChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
localVolume.value = parseInt(target.value, 10)
|
||||
const target = event.target as HTMLInputElement;
|
||||
localVolume.value = parseInt(target.value, 10);
|
||||
|
||||
// Debounce: attendre 300ms avant d'envoyer à l'API
|
||||
if (debounceTimer !== null) {
|
||||
clearTimeout(debounceTimer)
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
debounceTimer = window.setTimeout(async () => {
|
||||
try {
|
||||
await setVolume(props.rendererId, localVolume.value)
|
||||
await setVolume(props.rendererId, localVolume.value);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de régler le volume: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
uiStore.notifyError(
|
||||
`Impossible de régler le volume: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
debounceTimer = null
|
||||
}, 300)
|
||||
debounceTimer = null;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function handleToggleMute() {
|
||||
try {
|
||||
await toggleMute(props.rendererId)
|
||||
await toggleMute(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
uiStore.notifyError(
|
||||
`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -81,8 +89,6 @@ async function handleToggleMute() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
@@ -120,7 +126,7 @@ async function handleToggleMute() {
|
||||
}
|
||||
|
||||
.volume-control .btn-icon:active {
|
||||
transform: scale(1.0);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Slider avec effet glass */
|
||||
|
||||
@@ -1,371 +1,361 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { X, ChevronLeft, ChevronRight, Server } from 'lucide-vue-next'
|
||||
import { useTabs } from '@/composables/useTabs'
|
||||
import { useSwipe } from '@vueuse/core'
|
||||
import { computed } from "vue";
|
||||
import { Server, Music2 } from "lucide-vue-next";
|
||||
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||
import type {
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
} from "@/services/pmocontrol/types";
|
||||
|
||||
defineProps<{
|
||||
onlineServersCount?: number
|
||||
}>()
|
||||
const props = defineProps<{
|
||||
onlineServersCount?: number;
|
||||
onlineRenderersCount?: number;
|
||||
activeRenderer?: RendererSummary | null;
|
||||
activeRendererState?: RendererState | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-drawer': []
|
||||
}>()
|
||||
"open-drawer": [];
|
||||
"open-renderer-drawer": [];
|
||||
}>();
|
||||
|
||||
const { tabs, activeTabId, switchTab, closeTab, nextTab, previousTab, compactMode } = useTabs()
|
||||
|
||||
const tabBarRef = ref<HTMLElement | null>(null)
|
||||
const scrollContainerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Gestion du swipe pour changer d'onglet
|
||||
useSwipe(tabBarRef, {
|
||||
threshold: 50,
|
||||
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
|
||||
if (swipeDirection === 'left') {
|
||||
nextTab()
|
||||
} else if (swipeDirection === 'right') {
|
||||
previousTab()
|
||||
// Label du protocole
|
||||
const protocolLabel = computed(() => {
|
||||
if (!props.activeRenderer) return "";
|
||||
switch (props.activeRenderer.protocol) {
|
||||
case "upnp":
|
||||
return "UPnP";
|
||||
case "openhome":
|
||||
return "OpenHome";
|
||||
case "hybrid":
|
||||
return "Hybrid";
|
||||
case "chromecast":
|
||||
return "Chromecast";
|
||||
default:
|
||||
return props.activeRenderer.protocol;
|
||||
}
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Scroll vers l'onglet actif
|
||||
function scrollToActiveTab() {
|
||||
if (!scrollContainerRef.value) return
|
||||
|
||||
const activeTabElement = scrollContainerRef.value.querySelector('.tab-item.active')
|
||||
if (activeTabElement) {
|
||||
activeTabElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'center',
|
||||
})
|
||||
// Classe CSS du protocole
|
||||
const protocolClass = computed(() => {
|
||||
if (!props.activeRenderer) return "";
|
||||
switch (props.activeRenderer.protocol) {
|
||||
case "upnp":
|
||||
return "protocol-upnp";
|
||||
case "openhome":
|
||||
return "protocol-openhome";
|
||||
case "hybrid":
|
||||
return "protocol-hybrid";
|
||||
case "chromecast":
|
||||
return "protocol-chromecast";
|
||||
default:
|
||||
return "protocol-unknown";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll vers l'onglet actif quand il change
|
||||
watch(() => activeTabId.value, () => {
|
||||
scrollToActiveTab()
|
||||
})
|
||||
|
||||
// Gestion des boutons de scroll
|
||||
const showLeftScroll = ref(false)
|
||||
const showRightScroll = ref(false)
|
||||
|
||||
function updateScrollButtons() {
|
||||
if (!scrollContainerRef.value) return
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.value
|
||||
showLeftScroll.value = scrollLeft > 10
|
||||
showRightScroll.value = scrollLeft < scrollWidth - clientWidth - 10
|
||||
}
|
||||
|
||||
function scrollLeft() {
|
||||
if (!scrollContainerRef.value) return
|
||||
scrollContainerRef.value.scrollBy({ left: -200, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function scrollRight() {
|
||||
if (!scrollContainerRef.value) return
|
||||
scrollContainerRef.value.scrollBy({ left: 200, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (scrollContainerRef.value) {
|
||||
scrollContainerRef.value.addEventListener('scroll', updateScrollButtons)
|
||||
updateScrollButtons()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (scrollContainerRef.value) {
|
||||
scrollContainerRef.value.removeEventListener('scroll', updateScrollButtons)
|
||||
}
|
||||
})
|
||||
|
||||
// Gestion du clic sur un onglet
|
||||
function handleTabClick(tabId: string) {
|
||||
switchTab(tabId)
|
||||
}
|
||||
|
||||
// Gestion du clic sur le bouton fermer
|
||||
function handleCloseClick(event: Event, tabId: string) {
|
||||
event.stopPropagation()
|
||||
closeTab(tabId)
|
||||
}
|
||||
|
||||
// Gestion du clic sur le bouton server drawer
|
||||
function handleServerDrawerClick() {
|
||||
emit('open-drawer')
|
||||
emit("open-drawer");
|
||||
}
|
||||
|
||||
function handleRendererDrawerClick() {
|
||||
emit("open-renderer-drawer");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="tabBarRef" class="bottom-tab-bar">
|
||||
<!-- Bouton pour ouvrir le drawer des servers -->
|
||||
<div class="bottom-bar">
|
||||
<!-- Bouton pour ouvrir le drawer des servers (gauche) -->
|
||||
<button
|
||||
class="server-drawer-button"
|
||||
class="drawer-button server-drawer-button"
|
||||
@click="handleServerDrawerClick"
|
||||
:aria-label="`Open media servers (${onlineServersCount || 0} online)`"
|
||||
:title="`Media Servers (${onlineServersCount || 0} online)`"
|
||||
>
|
||||
<Server :size="24" />
|
||||
<span v-if="onlineServersCount && onlineServersCount > 0" class="server-badge">{{ onlineServersCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Bouton scroll gauche -->
|
||||
<button
|
||||
v-if="showLeftScroll"
|
||||
class="scroll-button scroll-left"
|
||||
@click="scrollLeft"
|
||||
aria-label="Scroll left"
|
||||
<span
|
||||
v-if="onlineServersCount && onlineServersCount > 0"
|
||||
class="badge server-badge"
|
||||
>{{ onlineServersCount }}</span
|
||||
>
|
||||
<ChevronLeft :size="20" />
|
||||
</button>
|
||||
|
||||
<!-- Container avec scroll horizontal -->
|
||||
<div ref="scrollContainerRef" class="tabs-scroll-container">
|
||||
<div class="tabs-container">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="tab-item"
|
||||
:class="{
|
||||
active: tab.id === activeTabId,
|
||||
compact: compactMode
|
||||
}"
|
||||
@click="handleTabClick(tab.id)"
|
||||
:title="tab.fullTitle"
|
||||
:aria-label="`Switch to ${tab.fullTitle} tab`"
|
||||
:aria-current="tab.id === activeTabId ? 'page' : undefined"
|
||||
<!-- Zone centrale avec les infos du renderer -->
|
||||
<div class="renderer-info-section">
|
||||
<div v-if="activeRenderer" class="renderer-info-content">
|
||||
<div class="renderer-name-row">
|
||||
<h2 class="renderer-name">
|
||||
{{ activeRenderer.friendly_name }}
|
||||
</h2>
|
||||
<span
|
||||
v-if="activeRenderer.protocol"
|
||||
:class="['protocol-badge', protocolClass]"
|
||||
>
|
||||
<!-- Icône (réduite pour plus d'espace pour le texte) -->
|
||||
<component :is="tab.icon" class="tab-icon" :size="compactMode ? 24 : 20" />
|
||||
|
||||
<!-- Titre (masqué en mode compact) -->
|
||||
<span v-if="!compactMode" class="tab-title">{{ tab.title }}</span>
|
||||
|
||||
<!-- Bouton fermer (seulement pour les onglets fermables, masqué en mode compact) -->
|
||||
<button
|
||||
v-if="tab.closeable && !compactMode"
|
||||
class="tab-close"
|
||||
@click="(e) => handleCloseClick(e, tab.id)"
|
||||
:aria-label="`Close ${tab.fullTitle} tab`"
|
||||
>
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</button>
|
||||
{{ protocolLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="renderer-details-row">
|
||||
<p v-if="activeRenderer.model_name" class="renderer-model">
|
||||
{{ activeRenderer.model_name }}
|
||||
</p>
|
||||
<StatusBadge
|
||||
v-if="activeRendererState"
|
||||
:status="activeRendererState.transport_state"
|
||||
class="status-badge"
|
||||
/>
|
||||
<span v-if="!activeRenderer.online" class="offline-badge">
|
||||
OFFLINE
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="renderer-info-content empty">
|
||||
<p class="no-renderer">Aucun renderer sélectionné</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton scroll droite -->
|
||||
<!-- Bouton pour ouvrir le drawer des renderers (droite) -->
|
||||
<button
|
||||
v-if="showRightScroll"
|
||||
class="scroll-button scroll-right"
|
||||
@click="scrollRight"
|
||||
aria-label="Scroll right"
|
||||
class="drawer-button renderer-drawer-button"
|
||||
@click="handleRendererDrawerClick"
|
||||
:aria-label="`Open renderers (${onlineRenderersCount || 0} online)`"
|
||||
:title="`Renderers (${onlineRenderersCount || 0} online)`"
|
||||
>
|
||||
<Music2 :size="24" />
|
||||
<span
|
||||
v-if="onlineRenderersCount && onlineRenderersCount > 0"
|
||||
class="badge renderer-badge"
|
||||
>{{ onlineRenderersCount }}</span
|
||||
>
|
||||
<ChevronRight :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bottom-tab-bar {
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
gap: var(--spacing-md);
|
||||
height: 72px;
|
||||
padding: 0 var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Support pour thème sombre */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bottom-tab-bar {
|
||||
.bottom-bar {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-scroll-container {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
|
||||
.tabs-scroll-container::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
/* Boutons drawer */
|
||||
.drawer-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 140px; /* Largeur fixe pour éviter l'espace blanc */
|
||||
height: 64px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 4px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px; /* Réduit de var(--text-sm) pour plus d'espace */
|
||||
font-family: inherit;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Mode compact: icônes seulement */
|
||||
.tab-item.compact {
|
||||
width: 60px;
|
||||
padding: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-bottom-color: var(--color-primary);
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tab-item:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
flex-shrink: 0;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
/* Permettre au texte de se replier sur 2 lignes */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.2;
|
||||
max-height: 2.4em; /* 2 lignes × 1.2 line-height */
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: var(--color-text);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.tab-close:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Boutons de scroll */
|
||||
.scroll-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.scroll-button:hover {
|
||||
.drawer-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.scroll-button:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
.drawer-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.scroll-left {
|
||||
left: 8px;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.drawer-button {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.drawer-button:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-right {
|
||||
right: 8px;
|
||||
/* Badges */
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
border: 2px solid var(--color-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Responsive mobile */
|
||||
@media (max-width: 768px) {
|
||||
.tab-item {
|
||||
width: 110px; /* Largeur fixe plus petite sur mobile */
|
||||
padding: 6px 10px;
|
||||
.server-badge {
|
||||
background: rgba(102, 126, 234, 0.9);
|
||||
}
|
||||
|
||||
.renderer-badge {
|
||||
background: rgba(234, 102, 126, 0.9);
|
||||
}
|
||||
|
||||
/* Zone centrale */
|
||||
.renderer-info-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.renderer-info-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px; /* Encore un peu plus petit sur mobile */
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.renderer-info-content.empty {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.renderer-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.renderer-details-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.no-renderer {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Protocol badge */
|
||||
.protocol-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.protocol-upnp {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-openhome {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
color: #8b5cf6;
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-hybrid {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.protocol-chromecast {
|
||||
background-color: rgba(244, 114, 182, 0.15);
|
||||
color: #f472b6;
|
||||
border: 1px solid rgba(244, 114, 182, 0.3);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.offline-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.bottom-bar {
|
||||
height: 64px;
|
||||
padding: 0 var(--spacing-sm);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
max-height: 2.6em; /* 2 lignes × 1.3 line-height */
|
||||
.drawer-button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
.renderer-name {
|
||||
font-size: var(--text-base);
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,72 +371,7 @@ function handleServerDrawerClick() {
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-tab-bar {
|
||||
.bottom-bar {
|
||||
animation: slideInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Effet de swipe visuel */
|
||||
.tabs-scroll-container {
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
/* Bouton server drawer */
|
||||
.server-drawer-button {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.server-drawer-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.server-drawer-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.server-drawer-button {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.server-drawer-button:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
/* Badge pour le nombre de servers online */
|
||||
.server-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: rgba(102, 126, 234, 0.9);
|
||||
border: 2px solid var(--color-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
762
pmoapp/webapp/src/components/unified/RendererDrawer.vue
Normal file
762
pmoapp/webapp/src/components/unified/RendererDrawer.vue
Normal file
@@ -0,0 +1,762 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from "vue";
|
||||
import { X, Music2, Circle, Settings, Play, Pause } from "lucide-vue-next";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import { useRouter } from "vue-router";
|
||||
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||
import type { RendererSummary } from "@/services/pmocontrol/types";
|
||||
import { api } from "@/services/pmocontrol/api";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean; // v-model pour contrôler l'ouverture
|
||||
selectedRendererId?: string | null; // ID du renderer actuellement sélectionné
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
"select-renderer": [rendererId: string];
|
||||
}>();
|
||||
|
||||
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
|
||||
const router = useRouter();
|
||||
|
||||
// Fonction pour obtenir l'état d'un renderer
|
||||
function getRendererState(rendererId: string) {
|
||||
return getStateById(rendererId);
|
||||
}
|
||||
|
||||
// Fonction pour obtenir le label du protocole
|
||||
function getProtocolLabel(protocol: string): string {
|
||||
switch (protocol) {
|
||||
case "upnp":
|
||||
return "UPnP";
|
||||
case "openhome":
|
||||
return "OpenHome";
|
||||
case "hybrid":
|
||||
return "Hybrid";
|
||||
case "chromecast":
|
||||
return "Chromecast";
|
||||
default:
|
||||
return protocol;
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction pour obtenir la classe CSS du protocole
|
||||
function getProtocolClass(protocol: string): string {
|
||||
switch (protocol) {
|
||||
case "upnp":
|
||||
return "protocol-upnp";
|
||||
case "openhome":
|
||||
return "protocol-openhome";
|
||||
case "hybrid":
|
||||
return "protocol-hybrid";
|
||||
case "chromecast":
|
||||
return "protocol-chromecast";
|
||||
default:
|
||||
return "protocol-unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// Rafraîchir la liste quand le drawer s'ouvre
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
fetchRenderers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const onlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r: RendererSummary) => r.online),
|
||||
);
|
||||
const offlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r: RendererSummary) => !r.online),
|
||||
);
|
||||
|
||||
function close() {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
function handleRendererClick(renderer: RendererSummary) {
|
||||
if (!renderer.online) return;
|
||||
|
||||
// Sélectionner le renderer et fermer le drawer
|
||||
emit("select-renderer", renderer.id);
|
||||
close();
|
||||
}
|
||||
|
||||
function handleSettingsClick() {
|
||||
router.push("/debug/api-dashboard");
|
||||
close();
|
||||
}
|
||||
|
||||
// Handlers pour les contrôles de transport
|
||||
async function handlePlay(event: Event, rendererId: string) {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await api.play(rendererId);
|
||||
} catch (error) {
|
||||
console.error("[RendererDrawer] Error playing:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePause(event: Event, rendererId: string) {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await api.pause(rendererId);
|
||||
} catch (error) {
|
||||
console.error("[RendererDrawer] Error pausing:", error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Backdrop -->
|
||||
<Transition name="backdrop">
|
||||
<div v-if="modelValue" class="drawer-backdrop" @click="close"></div>
|
||||
</Transition>
|
||||
|
||||
<!-- Drawer -->
|
||||
<Transition name="drawer">
|
||||
<aside v-if="modelValue" class="renderer-drawer">
|
||||
<!-- Header -->
|
||||
<header class="drawer-header">
|
||||
<div class="drawer-title-section">
|
||||
<Music2 :size="24" />
|
||||
<h2 class="drawer-title">Renderers</h2>
|
||||
</div>
|
||||
<button
|
||||
class="drawer-close-btn"
|
||||
@click="close"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X :size="24" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="drawer-content">
|
||||
<!-- Renderers online -->
|
||||
<section
|
||||
v-if="onlineRenderers.length > 0"
|
||||
class="renderer-section"
|
||||
>
|
||||
<h3 class="section-title">
|
||||
Disponibles ({{ onlineRenderers.length }})
|
||||
</h3>
|
||||
<ul class="renderer-list">
|
||||
<li
|
||||
v-for="renderer in onlineRenderers"
|
||||
:key="renderer.id"
|
||||
class="renderer-item online"
|
||||
:class="{
|
||||
selected:
|
||||
renderer.id === selectedRendererId,
|
||||
}"
|
||||
@click="handleRendererClick(renderer)"
|
||||
>
|
||||
<!-- Bouton transport à gauche -->
|
||||
<button
|
||||
v-if="getRendererState(renderer.id)"
|
||||
class="transport-btn"
|
||||
@click="
|
||||
getRendererState(renderer.id)
|
||||
?.transport_state === 'PLAYING'
|
||||
? handlePause($event, renderer.id)
|
||||
: getRendererState(renderer.id)
|
||||
?.transport_state ===
|
||||
'PAUSED'
|
||||
? handlePlay($event, renderer.id)
|
||||
: handlePlay($event, renderer.id)
|
||||
"
|
||||
:title="
|
||||
getRendererState(renderer.id)
|
||||
?.transport_state === 'PLAYING'
|
||||
? 'Pause'
|
||||
: 'Play'
|
||||
"
|
||||
>
|
||||
<Pause
|
||||
v-if="
|
||||
getRendererState(renderer.id)
|
||||
?.transport_state === 'PLAYING'
|
||||
"
|
||||
:size="18"
|
||||
/>
|
||||
<Play v-else :size="18" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="transport-btn disabled"
|
||||
disabled
|
||||
>
|
||||
<Play :size="18" />
|
||||
</button>
|
||||
|
||||
<div class="renderer-icon">
|
||||
<Music2 :size="20" />
|
||||
</div>
|
||||
<div class="renderer-info">
|
||||
<div class="renderer-name-row">
|
||||
<p class="renderer-name">
|
||||
{{ renderer.friendly_name }}
|
||||
</p>
|
||||
<span
|
||||
:class="[
|
||||
'protocol-badge',
|
||||
getProtocolClass(
|
||||
renderer.protocol,
|
||||
),
|
||||
]"
|
||||
>
|
||||
{{
|
||||
getProtocolLabel(
|
||||
renderer.protocol,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="renderer-details">
|
||||
<p
|
||||
v-if="renderer.model_name"
|
||||
class="renderer-model"
|
||||
>
|
||||
{{ renderer.model_name }}
|
||||
</p>
|
||||
<StatusBadge
|
||||
v-if="getRendererState(renderer.id)"
|
||||
:status="
|
||||
getRendererState(renderer.id)!
|
||||
.transport_state
|
||||
"
|
||||
class="renderer-state-badge"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="renderer-status">
|
||||
<Circle :size="8" fill="currentColor" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Renderers offline -->
|
||||
<section
|
||||
v-if="offlineRenderers.length > 0"
|
||||
class="renderer-section"
|
||||
>
|
||||
<h3 class="section-title">
|
||||
Hors ligne ({{ offlineRenderers.length }})
|
||||
</h3>
|
||||
<ul class="renderer-list">
|
||||
<li
|
||||
v-for="renderer in offlineRenderers"
|
||||
:key="renderer.id"
|
||||
class="renderer-item offline"
|
||||
>
|
||||
<div class="renderer-icon">
|
||||
<Music2 :size="20" />
|
||||
</div>
|
||||
<div class="renderer-info">
|
||||
<p class="renderer-name">
|
||||
{{ renderer.friendly_name }}
|
||||
</p>
|
||||
<p
|
||||
v-if="renderer.model_name"
|
||||
class="renderer-model"
|
||||
>
|
||||
{{ renderer.model_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="renderer-status">
|
||||
<Circle :size="8" fill="currentColor" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Aucun renderer -->
|
||||
<div
|
||||
v-if="allRenderers.length === 0"
|
||||
class="empty-renderers"
|
||||
>
|
||||
<Music2 :size="48" />
|
||||
<p>Aucun renderer détecté</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer avec bouton settings -->
|
||||
<footer class="drawer-footer">
|
||||
<button
|
||||
class="settings-btn"
|
||||
@click="handleSettingsClick"
|
||||
title="Ouvrir le menu Debug"
|
||||
>
|
||||
<Settings :size="20" />
|
||||
<span>Debug & Config</span>
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 50vw; /* Commence avant le drawer (desktop: jusqu'à 50vw) */
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.35); /* Moins sombre */
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.drawer-backdrop {
|
||||
right: 0; /* Mobile portrait: backdrop prend tout l'écran */
|
||||
background: rgba(0, 0, 0, 0.4); /* Plus sombre sur mobile */
|
||||
}
|
||||
}
|
||||
|
||||
.renderer-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 50vw; /* Desktop/landscape: 50% de l'écran */
|
||||
background: rgba(255, 255, 255, 0.08); /* Plus transparent */
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.25);
|
||||
z-index: 201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.renderer-drawer {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-left-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drawer-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
color: var(--color-text);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.drawer-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.drawer-close-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.renderer-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
padding: 0 var(--spacing-sm);
|
||||
}
|
||||
|
||||
.renderer-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.renderer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.renderer-item.online {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.renderer-item.online:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
.renderer-item.online:active {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.renderer-item.selected {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 16px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.renderer-item.selected:hover {
|
||||
background: rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
|
||||
.renderer-item.offline {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.renderer-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.renderer-item.selected .renderer-icon {
|
||||
background: rgba(102, 126, 234, 0.3);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.renderer-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.renderer-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.renderer-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.renderer-state-badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Protocol badge */
|
||||
.protocol-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.protocol-upnp {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-openhome {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
color: #8b5cf6;
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-hybrid {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.protocol-chromecast {
|
||||
background-color: rgba(244, 114, 182, 0.15);
|
||||
color: #f472b6;
|
||||
border: 1px solid rgba(244, 114, 182, 0.3);
|
||||
}
|
||||
|
||||
/* Transport button */
|
||||
.transport-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.transport-btn:hover:not(.disabled) {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.transport-btn:active:not(.disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.transport-btn.disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.renderer-status {
|
||||
flex-shrink: 0;
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.renderer-item.offline .renderer-status {
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
.empty-renderers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-2xl);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-renderers p {
|
||||
margin: 0;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.drawer-footer {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-md);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: var(--color-text);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.settings-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.backdrop-enter-active {
|
||||
transition: opacity 0.3s ease-out;
|
||||
transition-delay: 0.1s; /* Attend que le drawer soit un peu visible */
|
||||
}
|
||||
|
||||
.backdrop-leave-active {
|
||||
transition: opacity 0.25s ease-in;
|
||||
}
|
||||
|
||||
.backdrop-enter-from,
|
||||
.backdrop-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-enter-active {
|
||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); /* Courbe d'animation fluide (easeOutExpo) */
|
||||
}
|
||||
|
||||
.drawer-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.7, 0, 0.84, 0); /* Courbe d'animation de sortie (easeInExpo) */
|
||||
}
|
||||
|
||||
.drawer-enter-from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.drawer-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.drawer-content::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.drawer-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.drawer-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Mobile responsive - portrait */
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.renderer-drawer {
|
||||
width: 100vw; /* Mobile portrait: 100% de l'écran */
|
||||
background: rgba(
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
0.06
|
||||
); /* Encore plus transparent sur mobile */
|
||||
box-shadow: none; /* Pas d'ombre sur les côtés */
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.renderer-item {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.renderer-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback pour navigateurs sans backdrop-filter */
|
||||
@supports not (backdrop-filter: blur(30px)) {
|
||||
.renderer-drawer {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.renderer-drawer {
|
||||
background: rgba(20, 20, 30, 0.98);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,9 +6,7 @@ import CurrentTrack from "@/components/pmocontrol/CurrentTrack.vue";
|
||||
import TransportControls from "@/components/pmocontrol/TransportControls.vue";
|
||||
import VolumeControl from "@/components/pmocontrol/VolumeControl.vue";
|
||||
import QueueViewer from "@/components/pmocontrol/QueueViewer.vue";
|
||||
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||
import { ChevronUp, ChevronDown, Link } from "lucide-vue-next";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import { ChevronUp, ChevronDown } from "lucide-vue-next";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import { api } from "@/services/pmocontrol/api";
|
||||
import type { QueueItem } from "@/services/pmocontrol/types";
|
||||
@@ -17,10 +15,9 @@ const props = defineProps<{
|
||||
rendererId: string;
|
||||
}>();
|
||||
|
||||
const { renderer, state, queue, binding, refresh } = useRenderer(
|
||||
const { renderer, state, queue, refresh } = useRenderer(
|
||||
toRef(props, "rendererId"),
|
||||
);
|
||||
const { detachPlaylist } = useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Détection mobile portrait pour afficher le drawer au lieu de la colonne
|
||||
@@ -42,22 +39,6 @@ onMounted(async () => {
|
||||
|
||||
// État du renderer pour affichage
|
||||
const isOnline = computed(() => renderer.value?.online ?? false);
|
||||
const transportState = computed(
|
||||
() => state.value?.transport_state ?? "STOPPED",
|
||||
);
|
||||
const hasPlaylistBinding = computed(() => !!binding.value);
|
||||
|
||||
// Détacher la playlist
|
||||
async function handleDetachPlaylist() {
|
||||
try {
|
||||
await detachPlaylist(props.rendererId);
|
||||
uiStore.notifySuccess("Playlist détachée");
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Erreur: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Gérer le clic sur un item de la queue
|
||||
async function handleQueueItemClick(item: QueueItem) {
|
||||
@@ -86,42 +67,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
|
||||
<template>
|
||||
<div class="renderer-tab-content">
|
||||
<!-- Header avec nom du renderer et état -->
|
||||
<header class="renderer-header">
|
||||
<div class="header-info">
|
||||
<h1 class="renderer-name">
|
||||
{{ renderer?.friendly_name || "Renderer" }}
|
||||
</h1>
|
||||
<p v-if="renderer?.model_name" class="renderer-model">
|
||||
{{ renderer.model_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-badges">
|
||||
<StatusBadge v-if="state" :status="transportState" />
|
||||
<span v-if="renderer?.protocol" class="protocol-badge">
|
||||
{{ renderer.protocol.toUpperCase() }}
|
||||
</span>
|
||||
|
||||
<!-- Badge playlist binding avec tooltip -->
|
||||
<div
|
||||
v-if="hasPlaylistBinding"
|
||||
class="playlist-badge"
|
||||
:title="`Playlist liée\nServeur: ${binding?.server_id}\nContainer: ${binding?.container_id}`"
|
||||
>
|
||||
<button
|
||||
class="playlist-badge-btn"
|
||||
@click="handleDetachPlaylist"
|
||||
title="Cliquer pour détacher"
|
||||
>
|
||||
<Link :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span v-if="!isOnline" class="offline-badge">OFFLINE</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Layout principal -->
|
||||
<!-- Layout principal (header supprimé, infos déplacées dans BottomTabBar) -->
|
||||
<div class="renderer-layout" :class="{ 'queue-open': queueDrawerOpen }">
|
||||
<!-- Colonne gauche: Contrôles -->
|
||||
<div class="controls-column">
|
||||
@@ -198,114 +144,13 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.renderer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.header-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.protocol-badge,
|
||||
.offline-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.protocol-badge {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.offline-badge {
|
||||
background: var(--status-offline);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Badge playlist binding compact */
|
||||
.playlist-badge {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.playlist-badge-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(102, 126, 234, 0.4);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: rgba(102, 126, 234, 1);
|
||||
}
|
||||
|
||||
.playlist-badge-btn:hover {
|
||||
background: rgba(102, 126, 234, 0.3);
|
||||
border-color: rgba(102, 126, 234, 0.6);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.playlist-badge-btn:active {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.playlist-badge-btn {
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
border-color: rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.playlist-badge-btn:hover {
|
||||
background: rgba(102, 126, 234, 0.25);
|
||||
border-color: rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Layout principal - 800x600 landscape (2 colonnes) */
|
||||
.renderer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
padding: var(--spacing-lg) 0 var(--spacing-lg) var(--spacing-lg);
|
||||
/* padding-right: 0 pour coller la scrollbar au bord */
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -316,7 +161,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
padding-right: var(--spacing-sm);
|
||||
padding-right: 16px; /* Dégager la scrollbar */
|
||||
}
|
||||
|
||||
.current-track-section,
|
||||
@@ -373,10 +218,8 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
.renderer-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
padding-bottom: var(
|
||||
--spacing-xl
|
||||
); /* Espace pour éviter que la tab bar cache les contrôles */
|
||||
padding: var(--spacing-md) 0 var(--spacing-xl) var(--spacing-md);
|
||||
/* padding-right: 0 pour coller la scrollbar au bord */
|
||||
}
|
||||
|
||||
/* Queue drawer visible sur mobile (géré par v-if maintenant) */
|
||||
@@ -445,7 +288,6 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
}
|
||||
|
||||
.controls-column {
|
||||
padding-right: 0;
|
||||
padding-bottom: 100px; /* Espace supplémentaire pour éviter que la tab bar (64px) cache le volume */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSwipe } from "@vueuse/core";
|
||||
import BottomTabBar from "@/components/unified/BottomTabBar.vue";
|
||||
import EmptyState from "@/components/unified/EmptyState.vue";
|
||||
import ServerDrawer from "@/components/unified/ServerDrawer.vue";
|
||||
import RendererDrawer from "@/components/unified/RendererDrawer.vue";
|
||||
import RendererTabContent from "@/components/unified/RendererTabContent.vue";
|
||||
import ServerTabContent from "@/components/unified/ServerTabContent.vue";
|
||||
|
||||
@@ -17,11 +18,12 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { tabs, activeTabId, switchTab, activeTab, syncWithRenderers, isEmpty } =
|
||||
useTabs();
|
||||
const { allRenderers, fetchRenderers } = useRenderers();
|
||||
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
|
||||
const { allServers, fetchServers } = useMediaServers();
|
||||
|
||||
// État du drawer server
|
||||
// État des drawers
|
||||
const drawerOpen = ref(false);
|
||||
const rendererDrawerOpen = ref(false);
|
||||
|
||||
// Ref pour le swipe edge detection
|
||||
const viewRef = ref<HTMLElement | null>(null);
|
||||
@@ -50,16 +52,51 @@ const currentRendererId = computed(() => {
|
||||
return null;
|
||||
});
|
||||
|
||||
// Récupérer le renderer actif
|
||||
const activeRenderer = computed(() => {
|
||||
const rendererId = currentRendererId.value;
|
||||
if (!rendererId) return null;
|
||||
return allRenderers.value.find((r) => r.id === rendererId) || null;
|
||||
});
|
||||
|
||||
// Récupérer l'état du renderer actif
|
||||
const activeRendererState = computed(() => {
|
||||
const rendererId = currentRendererId.value;
|
||||
if (!rendererId) return null;
|
||||
return getStateById(rendererId);
|
||||
});
|
||||
|
||||
// Nombre de servers online pour afficher dans le badge
|
||||
const onlineServersCount = computed(
|
||||
() => allServers.value.filter((s) => s.online).length,
|
||||
);
|
||||
|
||||
// Nombre de renderers online pour afficher dans le badge
|
||||
const onlineRenderersCount = computed(
|
||||
() => allRenderers.value.filter((r) => r.online).length,
|
||||
);
|
||||
|
||||
// Gestion de l'ouverture du drawer depuis le bouton
|
||||
function handleDrawerOpen() {
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
// Gestion de l'ouverture du renderer drawer depuis le bouton
|
||||
function handleRendererDrawerOpen() {
|
||||
rendererDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
// Gestion de la sélection d'un renderer depuis le drawer
|
||||
function handleRendererSelect(rendererId: string) {
|
||||
// Chercher le tab correspondant à ce renderer ou le créer
|
||||
const existingTab = tabs.value.find(
|
||||
(t) => t.type === "renderer" && t.metadata?.rendererId === rendererId,
|
||||
);
|
||||
if (existingTab) {
|
||||
switchTab(existingTab.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync route query params avec l'état des tabs
|
||||
onMounted(async () => {
|
||||
// Fetch renderers et servers au montage
|
||||
@@ -162,10 +199,14 @@ const currentTabProps = computed(() => {
|
||||
</keep-alive>
|
||||
</main>
|
||||
|
||||
<!-- Barre d'onglets en bas -->
|
||||
<!-- Barre d'infos en bas -->
|
||||
<BottomTabBar
|
||||
:online-servers-count="onlineServersCount"
|
||||
:online-renderers-count="onlineRenderersCount"
|
||||
:active-renderer="activeRenderer"
|
||||
:active-renderer-state="activeRendererState"
|
||||
@open-drawer="handleDrawerOpen"
|
||||
@open-renderer-drawer="handleRendererDrawerOpen"
|
||||
/>
|
||||
|
||||
<!-- Drawer servers (swipe depuis bord gauche) -->
|
||||
@@ -173,6 +214,13 @@ const currentTabProps = computed(() => {
|
||||
v-model="drawerOpen"
|
||||
:selected-renderer-id="currentRendererId"
|
||||
/>
|
||||
|
||||
<!-- Drawer renderers (depuis bord droit) -->
|
||||
<RendererDrawer
|
||||
v-model="rendererDrawerOpen"
|
||||
:selected-renderer-id="currentRendererId"
|
||||
@select-renderer="handleRendererSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
use crate::{DeviceRegistry, discovery::upnp_provider::ParsedDeviceDescription};
|
||||
use crossbeam_channel::{Sender, bounded};
|
||||
use pmoupnp::ssdp::SsdpEvent;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::thread;
|
||||
|
||||
use crate::discovery::manager::UDNRegistry;
|
||||
|
||||
/// Gestionnaire des événements SSDP -> DeviceUpdate.
|
||||
/// Task to fetch a device description
|
||||
struct FetchTask {
|
||||
udn: String,
|
||||
location: String,
|
||||
server_header: String,
|
||||
max_age: u32,
|
||||
registry: Arc<RwLock<DeviceRegistry>>,
|
||||
}
|
||||
|
||||
/// Gestionnaire des événements SSDP -> DeviceUpdate.
|
||||
pub struct UpnpDiscoveryManager {
|
||||
device_registry: Arc<RwLock<DeviceRegistry>>,
|
||||
udn_cache: Arc<Mutex<UDNRegistry>>,
|
||||
fetch_sender: Sender<FetchTask>,
|
||||
}
|
||||
|
||||
impl UpnpDiscoveryManager {
|
||||
@@ -16,9 +27,39 @@ impl UpnpDiscoveryManager {
|
||||
device_registry: Arc<RwLock<DeviceRegistry>>,
|
||||
udn_cache: Arc<Mutex<UDNRegistry>>,
|
||||
) -> Self {
|
||||
// Create a bounded channel for fetch tasks (max 10 pending tasks)
|
||||
let (sender, receiver) = bounded::<FetchTask>(10);
|
||||
|
||||
// Spawn a pool of 3 worker threads to process fetch tasks
|
||||
for _ in 0..3 {
|
||||
let receiver = receiver.clone();
|
||||
thread::spawn(move || {
|
||||
while let Ok(task) = receiver.recv() {
|
||||
// Fetch + parse the device description (may take up to 5 seconds)
|
||||
if let Ok(info) = ParsedDeviceDescription::new(
|
||||
&task.udn,
|
||||
&task.location,
|
||||
&task.server_header,
|
||||
5,
|
||||
) {
|
||||
if let Some(renderer_info) = info.build_renderer() {
|
||||
if let Ok(mut reg) = task.registry.write() {
|
||||
reg.push_renderer(&renderer_info, task.max_age);
|
||||
}
|
||||
} else if let Some(server_info) = info.build_server() {
|
||||
if let Ok(mut reg) = task.registry.write() {
|
||||
reg.push_server(&server_info, task.max_age);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
device_registry,
|
||||
udn_cache,
|
||||
fetch_sender: sender,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,20 +89,18 @@ impl UpnpDiscoveryManager {
|
||||
UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, max_age as u64);
|
||||
|
||||
if should_fetch {
|
||||
// Fetch + parse the device description
|
||||
if let Ok(info) =
|
||||
ParsedDeviceDescription::new(&udn, &location, &server_header, 5)
|
||||
{
|
||||
if let Some(renderer_info) = info.build_renderer() {
|
||||
if let Ok(mut reg) = self.device_registry.write() {
|
||||
reg.push_renderer(&renderer_info, max_age);
|
||||
}
|
||||
} else if let Some(server_info) = info.build_server() {
|
||||
if let Ok(mut reg) = self.device_registry.write() {
|
||||
reg.push_server(&server_info, max_age);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Send fetch task to worker pool (non-blocking)
|
||||
// If the channel is full, try_send will fail and we skip this fetch
|
||||
let task = FetchTask {
|
||||
udn: udn.clone(),
|
||||
location: location.clone(),
|
||||
server_header: server_header.clone(),
|
||||
max_age,
|
||||
registry: Arc::clone(&self.device_registry),
|
||||
};
|
||||
|
||||
// Use try_send to avoid blocking if the queue is full
|
||||
let _ = self.fetch_sender.try_send(task);
|
||||
} else {
|
||||
// Even if we don't fetch, we MUST update last_seen to prevent timeout
|
||||
// This is critical: SSDP Alive messages arrive more frequently than max_age/2,
|
||||
|
||||
@@ -91,9 +91,13 @@ impl ControlPointState {
|
||||
tag = "control"
|
||||
)]
|
||||
async fn list_renderers(State(state): State<ControlPointState>) -> Json<Vec<RendererSummary>> {
|
||||
let renderers = state.control_point.list_music_renderers();
|
||||
// Use spawn_blocking to avoid blocking the tokio runtime
|
||||
// This is critical because list_music_renderers acquires a RwLock
|
||||
let control_point = state.control_point.clone();
|
||||
let summaries = tokio::task::spawn_blocking(move || {
|
||||
let renderers = control_point.list_music_renderers();
|
||||
|
||||
let summaries: Vec<RendererSummary> = renderers
|
||||
renderers
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let info = r.info();
|
||||
@@ -106,7 +110,10 @@ async fn list_renderers(State(state): State<ControlPointState>) -> Json<Vec<Rend
|
||||
online: r.is_online(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Json(summaries)
|
||||
}
|
||||
@@ -156,9 +163,21 @@ async fn get_renderer_full_snapshot(
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<FullRendererSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let snapshot = state
|
||||
.control_point
|
||||
.renderer_full_snapshot(&rid)
|
||||
|
||||
// Use spawn_blocking because renderer_full_snapshot does sync UPnP calls
|
||||
let control_point = state.control_point.clone();
|
||||
let rid_clone = rid.clone();
|
||||
let snapshot =
|
||||
tokio::task::spawn_blocking(move || control_point.renderer_full_snapshot(&rid_clone))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Task error: {}", e),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|err| map_snapshot_error(renderer_id, err))?;
|
||||
|
||||
Ok(Json(snapshot))
|
||||
@@ -1519,9 +1538,12 @@ async fn add_after_current(
|
||||
tag = "control"
|
||||
)]
|
||||
async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaServerSummary>> {
|
||||
let servers = state.control_point.list_media_servers().unwrap_or_default();
|
||||
// Use spawn_blocking to avoid blocking the tokio runtime
|
||||
let control_point = state.control_point.clone();
|
||||
let summaries = tokio::task::spawn_blocking(move || {
|
||||
let servers = control_point.list_media_servers().unwrap_or_default();
|
||||
|
||||
let summaries: Vec<MediaServerSummary> = servers
|
||||
servers
|
||||
.into_iter()
|
||||
.map(|s| MediaServerSummary {
|
||||
id: s.id().0.clone(),
|
||||
@@ -1529,7 +1551,10 @@ async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaS
|
||||
model_name: s.model_name().to_string(),
|
||||
online: s.is_online(),
|
||||
})
|
||||
.collect();
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Json(summaries)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ impl SsdpServer {
|
||||
&"0.0.0.0".parse().unwrap(),
|
||||
)?;
|
||||
|
||||
socket.set_read_timeout(Some(Duration::from_secs(1)))?;
|
||||
socket.set_multicast_loop_v4(false)?;
|
||||
|
||||
let socket = Arc::new(socket);
|
||||
@@ -274,6 +275,10 @@ impl SsdpServer {
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
// Timeout, continuer
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("❌ SSDP read error: {}", e);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user