push-yosuotuuuukv #42

Merged
eric merged 6 commits from push-yosuotuuuukv into main 2026-01-09 21:56:43 +01:00
23 changed files with 3230 additions and 1609 deletions

View File

@@ -71,22 +71,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.await
.expect("Failed to register MediaRenderer");
tracing::warn!("🔍 DEBUG: MediaRenderer registered, getting base_url...");
let base_url = renderer_instance.base_url();
tracing::warn!("🔍 DEBUG: Got base_url, getting description_route...");
let desc_route = renderer_instance.description_route();
tracing::warn!("🔍 DEBUG: Got description_route, logging...");
info!("✅ MediaRenderer ready at {}{}", base_url, desc_route);
tracing::warn!("🔍 DEBUG: Log complete!");
tracing::warn!("🔍 DEBUG: About to register MediaServer...");
let server_instance = server
.write()
.await
.register_device(MEDIA_SERVER.clone())
.await
.expect("Failed to register MediaServer");
tracing::warn!("🔍 DEBUG: MediaServer registered successfully");
// Enregistrer l'instance ContentDirectory pour les notifications GENA
if let Some(cd_service) = server_instance.get_service("ContentDirectory") {

View File

@@ -1,159 +1,178 @@
<script setup lang="ts">
import { computed, ref, watch, nextTick, toRef } from 'vue'
import { useRenderer } from '@/composables/useRenderers'
import QueueItem from './QueueItem.vue'
import { Link } from 'lucide-vue-next'
import type { QueueItem as QueueItemType } from '@/services/pmocontrol/types'
import { computed, ref, watch, nextTick, toRef } from "vue";
import { useRenderer } from "@/composables/useRenderers";
import QueueItem from "./QueueItem.vue";
import { Link } from "lucide-vue-next";
import type { QueueItem as QueueItemType } from "@/services/pmocontrol/types";
const props = defineProps<{
rendererId: string
}>()
rendererId: string;
}>();
const emit = defineEmits<{
clickItem: [item: QueueItemType]
}>()
clickItem: [item: QueueItemType];
}>();
const { queue, binding } = useRenderer(toRef(props, 'rendererId'))
const { queue, binding } = useRenderer(toRef(props, "rendererId"));
const isAttached = computed(() => !!binding.value)
const isAttached = computed(() => !!binding.value);
const queueContainer = ref<HTMLElement | null>(null)
const queueContainer = ref<HTMLElement | null>(null);
function handleItemClick(item: QueueItemType) {
emit('clickItem', item)
emit("clickItem", item);
}
// Auto-scroll vers la piste courante lors de l'ouverture
watch(() => queue.value?.current_index, async (currentIndex) => {
if (currentIndex !== null && currentIndex !== undefined && queueContainer.value) {
await nextTick()
const currentItem = queueContainer.value.querySelector('.queue-item.current')
if (currentItem) {
currentItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
}, { immediate: true })
watch(
() => queue.value?.current_index,
async (currentIndex) => {
if (
currentIndex !== null &&
currentIndex !== undefined &&
queueContainer.value
) {
await nextTick();
const currentItem = queueContainer.value.querySelector(
".queue-item.current",
);
if (currentItem) {
currentItem.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}
},
{ immediate: true },
);
</script>
<template>
<div class="queue-viewer">
<!-- Header avec indication de binding -->
<div class="queue-header">
<h3 class="queue-title">
File d'attente
<span class="queue-count" v-if="queue?.items.length">
({{ queue.items.length }})
</span>
</h3>
<div class="queue-viewer">
<!-- Header avec indication de binding -->
<div class="queue-header">
<h3 class="queue-title">
File d'attente
<span class="queue-count" v-if="queue?.items.length">
({{ queue.items.length }})
</span>
</h3>
<!-- Indicateur playlist attachée -->
<div v-if="isAttached" class="binding-indicator">
<Link :size="16" />
<span class="binding-text">
Attachée à une playlist
</span>
</div>
</div>
<!-- Indicateur playlist attachée -->
<div v-if="isAttached" class="binding-indicator">
<Link :size="16" />
<span class="binding-text"> Attachée à une playlist </span>
</div>
</div>
<!-- Liste des items -->
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
<QueueItem
v-for="item in queue.items"
:key="item.index"
:item="item"
:is-current="item.index === queue.current_index"
@click="handleItemClick"
/>
</div>
<!-- Liste des items -->
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
<QueueItem
v-for="item in queue.items"
:key="item.index"
:item="item"
:is-current="item.index === queue.current_index"
@click="handleItemClick"
/>
</div>
<!-- État vide -->
<div v-else class="queue-empty">
<p>Aucun élément dans la file d'attente</p>
<!-- État vide -->
<div v-else class="queue-empty">
<p>Aucun élément dans la file d'attente</p>
</div>
</div>
</div>
</template>
<style scoped>
.queue-viewer {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
height: 100%;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
height: 100%;
}
.queue-header {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.queue-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text);
margin: 0;
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text);
margin: 0;
}
.queue-count {
font-size: var(--text-sm);
font-weight: 400;
color: var(--color-text-secondary);
font-size: var(--text-sm);
font-weight: 400;
color: var(--color-text-secondary);
}
.binding-indicator {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background-color: var(--status-playing-bg);
color: var(--status-playing);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
border: 1px solid var(--status-playing);
width: fit-content;
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background-color: var(--status-playing-bg);
color: var(--status-playing);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
border: 1px solid var(--status-playing);
width: fit-content;
}
.binding-text {
font-size: var(--text-xs);
font-size: var(--text-xs);
}
.queue-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding-right: var(--spacing-xs);
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding-right: var(--spacing-xs);
}
/* Ajoute un espace de scroll en bas pour ne pas cacher les derniers items sous la barre */
.queue-list::after {
content: "";
display: block;
height: 80px; /* Espace pour la barre fixe en bas */
flex-shrink: 0;
}
/* Scrollbar styling */
.queue-list::-webkit-scrollbar {
width: 6px;
width: 6px;
}
.queue-list::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
border-radius: var(--radius-full);
background: var(--color-bg-secondary);
border-radius: var(--radius-full);
}
.queue-list::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: var(--radius-full);
background: var(--color-border);
border-radius: var(--radius-full);
}
.queue-list::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
background: var(--color-text-tertiary);
}
.queue-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-tertiary);
font-size: var(--text-base);
text-align: center;
padding: var(--spacing-xl);
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-tertiary);
font-size: var(--text-base);
text-align: center;
padding: var(--spacing-xl);
}
</style>

View File

@@ -1,286 +1,292 @@
<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) => {
if (newVolume !== undefined && newVolume !== null) {
localVolume.value = newVolume
}
}, { immediate: true })
watch(
() => state.value?.volume,
(newVolume) => {
if (newVolume !== undefined && newVolume !== null) {
localVolume.value = newVolume;
}
},
{ 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)
}
debounceTimer = window.setTimeout(async () => {
try {
await setVolume(props.rendererId, localVolume.value)
} catch (error) {
uiStore.notifyError(`Impossible de régler le volume: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
// Debounce: attendre 300ms avant d'envoyer à l'API
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
}
debounceTimer = null
}, 300)
debounceTimer = window.setTimeout(async () => {
try {
await setVolume(props.rendererId, localVolume.value);
} catch (error) {
uiStore.notifyError(
`Impossible de régler le volume: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
debounceTimer = null;
}, 300);
}
async function handleToggleMute() {
try {
await toggleMute(props.rendererId)
} catch (error) {
uiStore.notifyError(`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
}
try {
await toggleMute(props.rendererId);
} catch (error) {
uiStore.notifyError(
`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
}
</script>
<template>
<div class="volume-control">
<button
class="btn btn-icon"
@click="handleToggleMute"
:title="state?.mute ? 'Réactiver le son' : 'Couper le son'"
>
<VolumeX v-if="state?.mute" :size="20" />
<Volume2 v-else :size="20" />
</button>
<div class="volume-control">
<button
class="btn btn-icon"
@click="handleToggleMute"
:title="state?.mute ? 'Réactiver le son' : 'Couper le son'"
>
<VolumeX v-if="state?.mute" :size="20" />
<Volume2 v-else :size="20" />
</button>
<input
type="range"
min="0"
max="100"
:value="localVolume"
@input="handleVolumeChange"
class="volume-slider"
:disabled="state?.mute ?? false"
/>
<input
type="range"
min="0"
max="100"
:value="localVolume"
@input="handleVolumeChange"
class="volume-slider"
:disabled="state?.mute ?? false"
/>
<span class="volume-value">{{ localVolume }}</span>
</div>
<span class="volume-value">{{ localVolume }}</span>
</div>
</template>
<style scoped>
.volume-control {
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);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg);
border-radius: 20px;
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
.volume-control {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.12);
}
.volume-control {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.12);
}
}
/* Bouton mute avec effet glass */
.volume-control .btn-icon {
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
transition: all 0.3s ease;
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
transition: all 0.3s ease;
}
.volume-control .btn-icon:hover {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.volume-control .btn-icon:active {
transform: scale(1.0);
transform: scale(1);
}
/* Slider avec effet glass */
.volume-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 8px;
height: 8px;
outline: none;
transition: all 0.3s ease;
flex: 1;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 8px;
height: 8px;
outline: none;
transition: all 0.3s ease;
}
.volume-slider:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.25);
}
/* Thumb Webkit (Chrome, Safari) */
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
border: 2px solid rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: all 0.2s ease;
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
border: 2px solid rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: all 0.2s ease;
}
.volume-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
box-shadow:
0 6px 16px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
transform: scale(1.2);
box-shadow:
0 6px 16px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.volume-slider::-webkit-slider-thumb:active {
transform: scale(1.1);
transform: scale(1.1);
}
/* Thumb Mozilla */
.volume-slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
border: 2px solid rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: all 0.2s ease;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
border: 2px solid rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: all 0.2s ease;
}
.volume-slider::-moz-range-thumb:hover {
transform: scale(1.2);
transform: scale(1.2);
}
.volume-value {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
min-width: 2.5rem;
text-align: right;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
min-width: 2.5rem;
text-align: right;
}
.volume-slider:disabled {
opacity: 0.5;
cursor: not-allowed;
background: rgba(255, 255, 255, 0.1);
opacity: 0.5;
cursor: not-allowed;
background: rgba(255, 255, 255, 0.1);
}
.volume-slider:disabled::-webkit-slider-thumb {
cursor: not-allowed;
background: rgba(255, 255, 255, 0.5);
cursor: not-allowed;
background: rgba(255, 255, 255, 0.5);
}
.volume-slider:disabled::-moz-range-thumb {
cursor: not-allowed;
background: rgba(255, 255, 255, 0.5);
cursor: not-allowed;
background: rgba(255, 255, 255, 0.5);
}
@media (prefers-color-scheme: dark) {
.volume-control .btn-icon {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
}
.volume-control .btn-icon {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
}
.volume-control .btn-icon:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
.volume-control .btn-icon:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
.volume-slider {
background: rgba(255, 255, 255, 0.15);
}
.volume-slider {
background: rgba(255, 255, 255, 0.15);
}
}
/* Fallback pour navigateurs sans backdrop-filter */
@supports not (backdrop-filter: blur(20px)) {
.volume-control {
background: rgba(255, 255, 255, 0.95);
}
.volume-slider {
background: rgba(200, 200, 200, 0.8);
}
@media (prefers-color-scheme: dark) {
.volume-control {
background: rgba(0, 0, 0, 0.95);
background: rgba(255, 255, 255, 0.95);
}
.volume-slider {
background: rgba(100, 100, 100, 0.8);
background: rgba(200, 200, 200, 0.8);
}
@media (prefers-color-scheme: dark) {
.volume-control {
background: rgba(0, 0, 0, 0.95);
}
.volume-slider {
background: rgba(100, 100, 100, 0.8);
}
}
}
}
/* Mode kiosque - compactage pour petites hauteurs (800x600) */
@media (max-height: 700px) and (orientation: landscape) {
.volume-control {
padding: var(--spacing-sm) var(--spacing-md);
gap: var(--spacing-sm);
}
.volume-control {
padding: var(--spacing-sm) var(--spacing-md);
gap: var(--spacing-sm);
}
.volume-control .btn-icon {
width: 36px;
height: 36px;
min-width: 36px;
min-height: 36px;
}
.volume-control .btn-icon {
width: 36px;
height: 36px;
min-width: 36px;
min-height: 36px;
}
.volume-slider {
height: 6px;
}
.volume-slider {
height: 6px;
}
.volume-slider::-webkit-slider-thumb {
width: 18px;
height: 18px;
}
.volume-slider::-webkit-slider-thumb {
width: 18px;
height: 18px;
}
.volume-slider::-moz-range-thumb {
width: 18px;
height: 18px;
}
.volume-slider::-moz-range-thumb {
width: 18px;
height: 18px;
}
.volume-value {
font-size: 11px;
min-width: 2rem;
}
.volume-value {
font-size: 11px;
min-width: 2rem;
}
}
</style>

View File

@@ -1,452 +1,377 @@
<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
// 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";
}
});
const activeTabElement = scrollContainerRef.value.querySelector('.tab-item.active')
if (activeTabElement) {
activeTabElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
})
}
}
// 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 -->
<button
class="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"
>
<ChevronLeft :size="20" />
</button>
<!-- Container avec scroll horizontal -->
<div ref="scrollContainerRef" class="tabs-scroll-container">
<div class="tabs-container">
<div class="bottom-bar">
<!-- Bouton pour ouvrir le drawer des servers (gauche) -->
<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"
class="drawer-button server-drawer-button"
@click="handleServerDrawerClick"
:aria-label="`Open media servers (${onlineServersCount || 0} online)`"
:title="`Media Servers (${onlineServersCount || 0} online)`"
>
<!-- 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>
<Server :size="24" />
<span
v-if="onlineServersCount && onlineServersCount > 0"
class="badge server-badge"
>{{ onlineServersCount }}</span
>
</button>
</div>
</div>
<!-- Bouton scroll droite -->
<button
v-if="showRightScroll"
class="scroll-button scroll-right"
@click="scrollRight"
aria-label="Scroll right"
>
<ChevronRight :size="20" />
</button>
</div>
<!-- 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]"
>
{{ 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 pour ouvrir le drawer des renderers (droite) -->
<button
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
>
</button>
</div>
</template>
<style scoped>
.bottom-tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
height: 64px;
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 {
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 {
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;
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
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;
}
@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);
}
.bottom-bar {
background: rgba(0, 0, 0, 0.25);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
}
.tab-icon {
flex-shrink: 0;
color: currentColor;
/* Boutons drawer */
.drawer-button {
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
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.3s ease;
color: var(--color-text);
}
.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;
.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);
}
.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);
.drawer-button:active {
transform: scale(0.95);
}
.tab-close:hover {
background: rgba(255, 255, 255, 0.3);
color: var(--color-text);
transform: scale(1.1);
@media (prefers-color-scheme: dark) {
.drawer-button {
background: rgba(255, 255, 255, 0.15);
}
.drawer-button:hover {
background: rgba(255, 255, 255, 0.25);
}
}
.tab-close:active {
transform: scale(0.95);
/* 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);
}
/* 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;
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;
color: var(--color-text);
.server-badge {
background: rgba(102, 126, 234, 0.9);
}
.scroll-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-50%) scale(1.1);
.renderer-badge {
background: rgba(234, 102, 126, 0.9);
}
.scroll-button:active {
transform: translateY(-50%) scale(0.95);
/* Zone centrale */
.renderer-info-section {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
}
.scroll-left {
left: 8px;
}
.scroll-right {
right: 8px;
}
/* Responsive mobile */
@media (max-width: 768px) {
.tab-item {
width: 110px; /* Largeur fixe plus petite sur mobile */
padding: 6px 10px;
.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%;
}
.tab-title {
font-size: 12px;
line-height: 1.3;
max-height: 2.6em; /* 2 lignes × 1.3 line-height */
}
.renderer-info-content.empty {
color: var(--color-text-tertiary);
}
.tab-close {
width: 24px;
height: 24px;
}
.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);
}
.drawer-button {
width: 48px;
height: 48px;
}
.renderer-name {
font-size: var(--text-base);
max-width: 200px;
}
.renderer-model {
font-size: var(--text-xs);
}
}
/* Animation d'entrée */
@keyframes slideInUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.bottom-tab-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);
.bottom-bar {
animation: slideInUp 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,977 @@
<script setup lang="ts">
import { computed, watch, ref, onUnmounted } from "vue";
import {
X,
Music2,
Circle,
Settings,
Play,
Pause,
MoreVertical,
ArrowRightLeft,
} 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();
// Gestion du menu déroulant
const openMenuId = ref<string | null>(null);
// Gestionnaire pour fermer le menu quand on clique en dehors
let clickOutsideHandler: ((event: MouseEvent) => void) | null = null;
watch(openMenuId, (newValue) => {
if (newValue) {
// Ajouter l'écouteur après un petit délai pour éviter la fermeture immédiate
setTimeout(() => {
clickOutsideHandler = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest(".action-menu-container")) {
closeMenu();
}
};
document.addEventListener("click", clickOutsideHandler);
}, 100);
} else {
// Retirer l'écouteur
if (clickOutsideHandler) {
document.removeEventListener("click", clickOutsideHandler);
clickOutsideHandler = null;
}
}
});
onUnmounted(() => {
if (clickOutsideHandler) {
document.removeEventListener("click", clickOutsideHandler);
}
});
// 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);
}
}
// Gestion du menu déroulant
function toggleMenu(event: Event, rendererId: string) {
event.stopPropagation();
openMenuId.value = openMenuId.value === rendererId ? null : rendererId;
}
function closeMenu() {
openMenuId.value = null;
}
// Transfert de la queue
async function handleTransferQueue(event: Event, targetRendererId: string) {
event.stopPropagation();
closeMenu();
if (!props.selectedRendererId) {
console.warn("[RendererDrawer] No source renderer selected");
return;
}
if (props.selectedRendererId === targetRendererId) {
console.warn("[RendererDrawer] Cannot transfer to the same renderer");
return;
}
try {
console.log(
`[RendererDrawer] Transferring queue from ${props.selectedRendererId} to ${targetRendererId}`,
);
await api.transferQueue(props.selectedRendererId, targetRendererId);
// Sélectionner le nouveau renderer et fermer le drawer
emit("select-renderer", targetRendererId);
close();
} catch (error) {
console.error("[RendererDrawer] Error transferring queue:", 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>
<!-- Menu actions (uniquement si ce n'est pas le renderer sélectionné) -->
<div
v-if="
selectedRendererId &&
renderer.id !== selectedRendererId
"
class="action-menu-container"
>
<button
class="menu-btn"
@click="toggleMenu($event, renderer.id)"
:aria-label="`Actions pour ${renderer.friendly_name}`"
>
<MoreVertical :size="18" />
</button>
<Transition name="menu-fade">
<div
v-if="openMenuId === renderer.id"
class="action-dropdown"
>
<button
class="dropdown-item"
@click="
handleTransferQueue(
$event,
renderer.id,
)
"
>
<ArrowRightLeft :size="16" />
<span
>Transférer la lecture
ici</span
>
</button>
</div>
</Transition>
</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);
}
/* Menu actions */
.action-menu-container {
position: relative;
flex-shrink: 0;
}
.menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
color: var(--color-text-secondary);
transition: all 0.2s ease;
}
.menu-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--color-text);
}
.action-dropdown {
position: absolute;
right: 0;
top: calc(100% + 4px);
min-width: 200px;
background: var(--color-surface-elevated);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
overflow: hidden;
z-index: 1000;
}
@media (prefers-color-scheme: light) {
.action-dropdown {
background: white;
border-color: rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
.dropdown-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
background: none;
border: none;
text-align: left;
cursor: pointer;
transition: background-color 0.2s ease;
color: var(--color-text);
font-size: var(--text-sm);
}
.dropdown-item:hover {
background: rgba(102, 126, 234, 0.1);
}
.dropdown-item:active {
background: rgba(102, 126, 234, 0.2);
}
.dropdown-item span {
flex: 1;
}
/* Transitions pour le menu */
.menu-fade-enter-active {
transition: all 0.15s ease-out;
}
.menu-fade-leave-active {
transition: all 0.1s ease-in;
}
.menu-fade-enter-from {
opacity: 0;
transform: translateY(-4px);
}
.menu-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.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>

View File

@@ -1,475 +1,372 @@
<script setup lang="ts">
import { ref, computed, toRef, onMounted } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useRenderer } from '@/composables/useRenderers'
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 { useUIStore } from '@/stores/ui'
import { api } from '@/services/pmocontrol/api'
import type { QueueItem } from '@/services/pmocontrol/types'
import { ref, computed, toRef, onMounted } from "vue";
import { useMediaQuery } from "@vueuse/core";
import { useRenderer } from "@/composables/useRenderers";
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 { ChevronUp, ChevronDown } from "lucide-vue-next";
import { useUIStore } from "@/stores/ui";
import { api } from "@/services/pmocontrol/api";
import type { QueueItem } from "@/services/pmocontrol/types";
const props = defineProps<{
rendererId: string
}>()
rendererId: string;
}>();
const { renderer, state, queue, binding, refresh } = useRenderer(toRef(props, 'rendererId'))
const { detachPlaylist } = useRenderers()
const uiStore = useUIStore()
const { renderer, state, queue, refresh } = useRenderer(
toRef(props, "rendererId"),
);
const uiStore = useUIStore();
// Détection mobile portrait pour afficher le drawer au lieu de la colonne
const isMobilePortrait = useMediaQuery('(max-width: 768px) and (orientation: portrait)')
const isMobilePortrait = useMediaQuery(
"(max-width: 768px) and (orientation: portrait)",
);
// État du drawer queue sur mobile
const queueDrawerOpen = ref(false)
const queueDrawerOpen = ref(false);
function toggleQueueDrawer() {
queueDrawerOpen.value = !queueDrawerOpen.value
queueDrawerOpen.value = !queueDrawerOpen.value;
}
// Charger les données au montage
onMounted(async () => {
await refresh()
})
await refresh();
});
// É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'}`)
}
}
const isOnline = computed(() => renderer.value?.online ?? false);
// Gérer le clic sur un item de la queue
async function handleQueueItemClick(item: QueueItem) {
try {
await api.seekQueueIndex(props.rendererId, item.index)
console.log('[RendererTabContent] Jumped to queue index:', item.index, item.title)
try {
await api.seekQueueIndex(props.rendererId, item.index);
console.log(
"[RendererTabContent] Jumped to queue index:",
item.index,
item.title,
);
// Force un refetch immédiat pour synchroniser la cover affichée
// sans attendre l'événement SSE qui peut avoir un délai
await refresh(true)
} catch (error) {
console.error('[RendererTabContent] Error seeking to queue index:', error)
uiStore.notifyError(`Erreur: ${error instanceof Error ? error.message : 'Impossible de sauter à cet item'}`)
}
// Force un refetch immédiat pour synchroniser la cover affichée
// sans attendre l'événement SSE qui peut avoir un délai
await refresh(true);
} catch (error) {
console.error(
"[RendererTabContent] Error seeking to queue index:",
error,
);
uiStore.notifyError(
`Erreur: ${error instanceof Error ? error.message : "Impossible de sauter à cet item"}`,
);
}
}
</script>
<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>
<div class="renderer-tab-content">
<!-- 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">
<!-- Pochette + infos track -->
<CurrentTrack
v-if="state"
:renderer-id="rendererId"
class="current-track-section"
/>
<!-- 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>
<!-- Contrôles de transport -->
<div v-if="state" class="controls-section">
<TransportControls :renderer-id="rendererId" />
</div>
<!-- Contrôle de volume -->
<div v-if="state" class="volume-section">
<VolumeControl :renderer-id="rendererId" />
</div>
<!-- Message si offline -->
<div v-if="!isOnline" class="offline-message">
<p>Ce renderer est actuellement hors ligne</p>
</div>
</div>
<!-- Colonne droite: Queue (desktop et landscape uniquement) -->
<div v-if="!isMobilePortrait" class="queue-column">
<QueueViewer
:renderer-id="rendererId"
class="queue-viewer"
@click-item="handleQueueItemClick"
/>
</div>
<!-- Drawer queue (mobile portrait uniquement) -->
<div
v-if="isMobilePortrait"
class="queue-drawer"
:class="{ open: queueDrawerOpen }"
>
<!-- Toggle button -->
<button class="queue-drawer-toggle" @click="toggleQueueDrawer">
<ChevronUp v-if="queueDrawerOpen" :size="24" />
<ChevronDown v-else :size="24" />
<span>File d'attente ({{ queue?.items.length || 0 }})</span>
</button>
<!-- Contenu du drawer -->
<div class="queue-drawer-content">
<QueueViewer
:renderer-id="rendererId"
@click-item="handleQueueItemClick"
/>
</div>
</div>
<!-- Backdrop pour fermer le drawer -->
<div
v-if="queueDrawerOpen"
class="queue-drawer-backdrop"
@click="queueDrawerOpen = false"
></div>
</div>
<span v-if="!isOnline" class="offline-badge">OFFLINE</span>
</div>
</header>
<!-- Layout principal -->
<div class="renderer-layout" :class="{ 'queue-open': queueDrawerOpen }">
<!-- Colonne gauche: Contrôles -->
<div class="controls-column">
<!-- Pochette + infos track -->
<CurrentTrack v-if="state" :renderer-id="rendererId" class="current-track-section" />
<!-- Contrôles de transport -->
<div v-if="state" class="controls-section">
<TransportControls :renderer-id="rendererId" />
</div>
<!-- Contrôle de volume -->
<div v-if="state" class="volume-section">
<VolumeControl :renderer-id="rendererId" />
</div>
<!-- Message si offline -->
<div v-if="!isOnline" class="offline-message">
<p>Ce renderer est actuellement hors ligne</p>
</div>
</div>
<!-- Colonne droite: Queue (desktop et landscape uniquement) -->
<div v-if="!isMobilePortrait" class="queue-column">
<QueueViewer :renderer-id="rendererId" class="queue-viewer" @click-item="handleQueueItemClick" />
</div>
<!-- Drawer queue (mobile portrait uniquement) -->
<div v-if="isMobilePortrait" class="queue-drawer" :class="{ open: queueDrawerOpen }">
<!-- Toggle button -->
<button class="queue-drawer-toggle" @click="toggleQueueDrawer">
<ChevronUp v-if="queueDrawerOpen" :size="24" />
<ChevronDown v-else :size="24" />
<span>File d'attente ({{ queue?.items.length || 0 }})</span>
</button>
<!-- Contenu du drawer -->
<div class="queue-drawer-content">
<QueueViewer :renderer-id="rendererId" @click-item="handleQueueItemClick" />
</div>
</div>
<!-- Backdrop pour fermer le drawer -->
<div
v-if="queueDrawerOpen"
class="queue-drawer-backdrop"
@click="queueDrawerOpen = false"
></div>
</div>
</div>
</template>
<style scoped>
.renderer-tab-content {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
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.0);
}
@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);
}
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
}
/* Layout principal - 800x600 landscape (2 colonnes) */
.renderer-layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
flex: 1;
overflow: hidden;
display: grid;
grid-template-columns: 300px 1fr;
gap: 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;
}
/* Colonne gauche - Contrôles */
.controls-column {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
overflow-y: auto;
padding-right: var(--spacing-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
overflow-y: auto;
padding-right: 16px; /* Dégager la scrollbar */
}
.current-track-section,
.controls-section,
.volume-section,
.playlist-section {
flex-shrink: 0;
flex-shrink: 0;
}
.offline-message {
padding: var(--spacing-lg);
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-md);
text-align: center;
color: var(--status-offline);
padding: var(--spacing-lg);
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-md);
text-align: center;
color: var(--status-offline);
}
/* Colonne droite - Queue */
.queue-column {
display: flex;
flex-direction: column;
overflow: hidden;
display: flex;
flex-direction: column;
overflow: hidden;
}
.queue-viewer {
flex: 1;
overflow-y: auto;
flex: 1;
overflow-y: auto;
}
/* Queue drawer - masqué sur desktop, visible uniquement sur mobile portrait */
.queue-drawer {
display: none;
display: none;
}
.queue-drawer-backdrop {
display: none;
display: none;
}
/* Responsive - Mobile portrait */
@media (max-width: 768px) and (orientation: portrait) {
.renderer-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-sm);
padding: var(--spacing-md);
}
.renderer-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-sm);
padding: var(--spacing-md);
}
.header-badges {
width: 100%;
justify-content: flex-start;
}
.header-badges {
width: 100%;
justify-content: flex-start;
}
.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 */
}
.renderer-layout {
grid-template-columns: 1fr;
gap: var(--spacing-md);
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) */
.queue-drawer {
position: fixed;
bottom: 64px; /* Hauteur de la tab bar */
left: 0;
right: 0;
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.2);
transform: translateY(calc(100% - 56px));
transition: transform 0.3s ease;
z-index: 90;
max-height: 70vh;
}
/* Queue drawer visible sur mobile (géré par v-if maintenant) */
.queue-drawer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
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.2);
/* Fermé: caché sauf le toggle (56px) qui dépasse au-dessus de la BottomTabBar (64px) */
transform: translateY(calc(100% - 56px - 64px));
transition: transform 0.3s ease;
z-index: 95; /* Au-dessus de la BottomTabBar (z-index: 100) */
max-height: 70vh;
display: flex;
flex-direction: column;
/* Fermé: ne bloque pas les clics en dehors du toggle */
pointer-events: none;
}
.queue-drawer.open {
transform: translateY(0);
}
.queue-drawer.open {
/* Ouvert: remonte mais s'arrête à 64px du bas pour laisser la BottomTabBar accessible */
transform: translateY(64px);
pointer-events: auto; /* Ouvert: capture les clics */
}
.queue-drawer-toggle {
width: 100%;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
background: transparent;
border: none;
cursor: pointer;
color: var(--color-text);
font-size: var(--text-base);
font-weight: 600;
font-family: inherit;
}
.queue-drawer-toggle {
width: 100%;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
background: transparent;
border: none;
cursor: pointer;
color: var(--color-text);
font-size: var(--text-base);
font-weight: 600;
font-family: inherit;
pointer-events: auto; /* Le bouton est toujours cliquable */
}
.queue-drawer-content {
max-height: calc(70vh - 56px);
overflow-y: auto;
padding: var(--spacing-md);
}
.queue-drawer-content {
max-height: calc(70vh - 56px);
overflow-y: auto;
padding: var(--spacing-md);
}
.queue-drawer-backdrop {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 64px;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 89;
}
.queue-drawer-backdrop {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 64px; /* S'arrête au-dessus de la BottomTabBar */
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 94; /* Entre la BottomTabBar et le drawer */
pointer-events: auto; /* Capture les clics pour fermer le drawer */
}
.controls-column {
padding-right: 0;
padding-bottom: 100px; /* Espace supplémentaire pour éviter que la tab bar (64px) cache le volume */
}
.controls-column {
padding-bottom: 100px; /* Espace supplémentaire pour éviter que la tab bar (64px) cache le volume */
}
}
/* Responsive - 800x600 landscape et petites hauteurs (mode kiosque) */
@media (min-width: 600px) and (max-width: 1024px) and (orientation: landscape) {
.renderer-layout {
grid-template-columns: 280px 1fr;
gap: var(--spacing-md);
padding: var(--spacing-md);
}
.renderer-layout {
grid-template-columns: 280px 1fr;
gap: var(--spacing-md);
padding: var(--spacing-md);
}
.renderer-header {
padding: var(--spacing-sm) var(--spacing-md);
}
.renderer-header {
padding: var(--spacing-sm) var(--spacing-md);
}
.renderer-name {
font-size: var(--text-xl);
}
.renderer-name {
font-size: var(--text-xl);
}
}
/* Mode kiosque - compactage pour hauteurs ≤ 700px (ex: 800x600) */
@media (max-height: 700px) and (orientation: landscape) {
.renderer-header {
padding: var(--spacing-xs) var(--spacing-md);
}
.renderer-header {
padding: var(--spacing-xs) var(--spacing-md);
}
.renderer-name {
font-size: var(--text-lg);
}
.renderer-name {
font-size: var(--text-lg);
}
.renderer-model {
font-size: 11px;
}
.renderer-model {
font-size: 11px;
}
.renderer-layout {
gap: var(--spacing-sm);
padding: var(--spacing-sm);
}
.renderer-layout {
gap: var(--spacing-sm);
padding: var(--spacing-sm);
}
.controls-column {
gap: var(--spacing-sm);
}
.controls-column {
gap: var(--spacing-sm);
}
/* Masquer le scroll, tout doit tenir */
.controls-column {
overflow-y: visible;
}
/* Masquer le scroll, tout doit tenir */
.controls-column {
overflow-y: visible;
}
}
/* Large desktop */
@media (min-width: 1200px) {
.renderer-layout {
grid-template-columns: 350px 1fr;
max-width: 1400px;
margin: 0 auto;
}
.renderer-layout {
grid-template-columns: 350px 1fr;
max-width: 1400px;
margin: 0 auto;
}
}
/* Scrollbar styling */
.controls-column::-webkit-scrollbar,
.queue-viewer::-webkit-scrollbar {
width: 6px;
width: 6px;
}
.controls-column::-webkit-scrollbar-track,
.queue-viewer::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
.controls-column::-webkit-scrollbar-thumb,
.queue-viewer::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
.controls-column::-webkit-scrollbar-thumb:hover,
.queue-viewer::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.3);
}
</style>

View File

@@ -12,8 +12,10 @@ import {
Play,
Plus,
Settings,
MoreVertical,
} from "lucide-vue-next";
import { useMediaServers } from "@/composables/useMediaServers";
import { useRenderers } from "@/composables/useRenderers";
import type {
MediaServerSummary,
BrowseResponse,
@@ -22,12 +24,11 @@ import type {
const props = defineProps<{
modelValue: boolean; // v-model pour contrôler l'ouverture
selectedRendererId?: string | null; // ID du renderer sélectionné
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
"play-item": [item: ContainerEntry, serverId: string];
"queue-item": [item: ContainerEntry, serverId: string];
}>();
const {
@@ -38,6 +39,10 @@ const {
setPath,
clearPath,
} = useMediaServers();
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
useRenderers();
const router = useRouter();
// État de navigation
@@ -45,6 +50,9 @@ const currentServer = ref<MediaServerSummary | null>(null);
const browseData = ref<BrowseResponse | null>(null);
const isLoading = ref(false);
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)
const openMenuId = ref<string | null>(null);
// Rafraîchir la liste quand le drawer s'ouvre
watch(
() => props.modelValue,
@@ -56,6 +64,34 @@ watch(
currentServer.value = null;
browseData.value = null;
clearPath();
closeMenu();
}
},
);
// Fermer le menu quand on clique ailleurs (utilise un seul listener global)
let clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
watch(
() => openMenuId.value,
(menuId) => {
// Nettoyer l'ancien listener s'il existe
if (clickOutsideHandler) {
document.removeEventListener("click", clickOutsideHandler);
clickOutsideHandler = null;
}
// Ajouter un nouveau listener seulement si un menu est ouvert
if (menuId) {
clickOutsideHandler = () => {
closeMenu();
};
// Utiliser setTimeout pour éviter que le clic qui ouvre le menu le ferme immédiatement
setTimeout(() => {
if (clickOutsideHandler) {
document.addEventListener("click", clickOutsideHandler);
}
}, 0);
}
},
);
@@ -169,16 +205,85 @@ function handleItemClick(item: ContainerEntry) {
// Les tracks individuels : on ne fait rien (actions via boutons)
}
function handlePlayItem(event: Event, item: ContainerEntry) {
function toggleMenu(itemId: string, event: Event) {
event.stopPropagation();
if (!currentServer.value) return;
emit("play-item", item, currentServer.value.id);
openMenuId.value = openMenuId.value === itemId ? null : itemId;
}
function handleQueueItem(event: Event, item: ContainerEntry) {
function closeMenu() {
openMenuId.value = null;
}
async function handlePlayItem(event: Event, item: ContainerEntry) {
event.stopPropagation();
if (!currentServer.value) return;
emit("queue-item", item, currentServer.value.id);
closeMenu();
if (!currentServer.value || !props.selectedRendererId) {
console.warn("[ServerDrawer] No server or renderer selected");
return;
}
try {
if (item.is_container) {
// Container : attacher comme playlist avec auto_play
await attachAndPlayPlaylist(
props.selectedRendererId,
currentServer.value.id,
item.id,
);
} else {
// Item : vider queue + ajouter + jouer
await playContent(
props.selectedRendererId,
currentServer.value.id,
item.id,
);
}
} catch (error) {
console.error("[ServerDrawer] Error playing item:", error);
}
}
async function handleAddToQueue(event: Event, item: ContainerEntry) {
event.stopPropagation();
closeMenu();
if (!currentServer.value || !props.selectedRendererId) {
console.warn("[ServerDrawer] No server or renderer selected");
return;
}
try {
// Détacher le binding (fait côté serveur) + ajouter à la fin
await addToQueue(
props.selectedRendererId,
currentServer.value.id,
item.id,
);
} catch (error) {
console.error("[ServerDrawer] Error adding to queue:", error);
}
}
async function handleAddAfterCurrent(event: Event, item: ContainerEntry) {
event.stopPropagation();
closeMenu();
if (!currentServer.value || !props.selectedRendererId) {
console.warn("[ServerDrawer] No server or renderer selected");
return;
}
try {
// Détacher le binding + insérer après current
await addAfterCurrent(
props.selectedRendererId,
currentServer.value.id,
item.id,
);
} catch (error) {
console.error("[ServerDrawer] Error adding after current:", error);
}
}
function handleSettingsClick() {
@@ -358,6 +463,7 @@ function handleSettingsClick() {
:class="{
navigable:
item.is_container && isNavigable(item),
'menu-open': openMenuId === item.id,
}"
@click="handleItemClick(item)"
>
@@ -433,6 +539,7 @@ function handleSettingsClick() {
v-if="isPlayable(item)"
class="content-actions"
>
<!-- Bouton Play -->
<button
class="action-btn play-btn"
:title="
@@ -444,17 +551,54 @@ function handleSettingsClick() {
>
<Play :size="16" />
</button>
<button
class="action-btn queue-btn"
:title="
item.is_container
? 'Ajouter tout à la queue'
: 'Ajouter à la queue'
"
@click="handleQueueItem($event, item)"
>
<Plus :size="16" />
</button>
<!-- Bouton Menu avec dropdown -->
<div class="action-menu-container">
<button
class="action-btn menu-btn"
:title="'Plus d\'actions'"
@click="toggleMenu(item.id, $event)"
>
<MoreVertical :size="16" />
</button>
<!-- Dropdown menu -->
<Transition name="menu-fade">
<div
v-if="openMenuId === item.id"
class="action-dropdown"
@click.stop
>
<button
class="dropdown-item"
@click="
handleAddToQueue(
$event,
item,
)
"
>
<Plus :size="14" />
<span
>Ajouter à la
queue</span
>
</button>
<button
class="dropdown-item"
@click="
handleAddAfterCurrent(
$event,
item,
)
"
>
<Plus :size="14" />
<span>Ajouter après</span>
</button>
</div>
</Transition>
</div>
</div>
<!-- Chevron pour containers navigables (même si jouables) -->
@@ -507,7 +651,8 @@ function handleSettingsClick() {
@media (max-width: 768px) and (orientation: portrait) {
.drawer-backdrop {
left: 80vw; /* Mobile portrait: 80vw */
left: 0; /* Mobile portrait: backdrop commence à gauche car drawer prend 100vw */
background: rgba(0, 0, 0, 0.4); /* Plus sombre sur mobile */
}
}
@@ -517,11 +662,11 @@ function handleSettingsClick() {
left: 0;
bottom: 0;
width: 50vw; /* Desktop/landscape: 50% de l'écran */
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(30px) saturate(180%);
-webkit-backdrop-filter: blur(30px) saturate(180%);
border-right: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.2);
background: rgba(255, 255, 255, 0.08); /* Plus transparent */
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
border-right: 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;
@@ -794,6 +939,10 @@ function handleSettingsClick() {
transform: translateX(1px);
}
.content-item.menu-open {
z-index: 200; /* Passe au-dessus des autres items quand son menu est ouvert */
}
/* Cover avec image */
.content-cover {
position: relative;
@@ -884,7 +1033,8 @@ function handleSettingsClick() {
flex-shrink: 0;
opacity: 1; /* Toujours visible pour le tactile */
transition: all 0.2s ease;
z-index: 1; /* Au-dessus pour capturer les clicks */
z-index: 10; /* Au-dessus pour capturer les clicks */
position: relative; /* Crée un contexte de stacking */
}
.action-btn {
@@ -923,15 +1073,92 @@ function handleSettingsClick() {
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
}
.queue-btn {
.menu-btn {
color: var(--color-text-secondary);
}
.queue-btn:hover {
.menu-btn:hover {
background: rgba(255, 255, 255, 0.25);
color: var(--color-text);
}
/* Menu dropdown container */
.action-menu-container {
position: relative;
z-index: 100; /* Plus élevé que les content-item pour que le dropdown passe au-dessus */
}
/* Dropdown menu */
.action-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 180px;
background: rgba(20, 20, 30, 0.98);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
padding: 4px;
z-index: 10000; /* Très haut pour passer au-dessus de tout */
}
@media (prefers-color-scheme: light) {
.action-dropdown {
background: rgba(255, 255, 255, 0.98);
border-color: rgba(0, 0, 0, 0.1);
}
}
/* Dropdown items */
.dropdown-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
border: none;
border-radius: 6px;
color: var(--color-text);
font-size: var(--text-sm);
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
}
.dropdown-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.dropdown-item:active {
transform: scale(0.98);
}
.dropdown-item span {
flex: 1;
}
/* Menu fade animation */
.menu-fade-enter-active {
transition: all 0.15s ease-out;
}
.menu-fade-leave-active {
transition: all 0.1s ease-in;
}
.menu-fade-enter-from {
opacity: 0;
transform: translateY(-8px) scale(0.95);
}
.menu-fade-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
/* Loading state */
.loading-state {
display: flex;
@@ -1017,12 +1244,12 @@ function handleSettingsClick() {
/* Animations */
.backdrop-enter-active {
transition: opacity 0.2s ease;
transition-delay: 0.15s; /* Attend que le drawer soit à moitié visible */
transition: opacity 0.3s ease-out;
transition-delay: 0.1s; /* Attend que le drawer soit un peu visible */
}
.backdrop-leave-active {
transition: opacity 0.2s ease;
transition: opacity 0.25s ease-in;
/* Pas de delay au leave - disparaît en même temps que le drawer */
}
@@ -1031,16 +1258,26 @@ function handleSettingsClick() {
opacity: 0;
}
.drawer-enter-active,
.drawer-leave-active {
transition: transform 0.3s ease;
.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-enter-from,
.drawer-leave-to {
transform: translateX(-100%);
opacity: 0;
}
/* Animation des contenus - désactivée pour éviter les problèmes de z-index en escalier */
/* Scrollbar styling */
.drawer-content::-webkit-scrollbar,
.breadcrumb::-webkit-scrollbar {
@@ -1068,7 +1305,14 @@ function handleSettingsClick() {
/* Mobile responsive - portrait */
@media (max-width: 768px) and (orientation: portrait) {
.server-drawer {
width: 80vw; /* Mobile portrait: 80% de l'écran */
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 {
@@ -1078,6 +1322,16 @@ function handleSettingsClick() {
.drawer-title {
font-size: var(--text-lg);
}
/* Ajuster les items pour mobile */
.content-item {
padding: var(--spacing-md);
}
.content-cover {
width: 64px;
height: 64px;
}
}
/* Fallback pour navigateurs sans backdrop-filter */

View File

@@ -4,28 +4,28 @@
* - Les snapshots complets proviennent de /renderers/{id}/full
* - Les événements SSE ne servent qu'à déclencher un refetch.
*/
import { ref, reactive, computed, type Ref } from 'vue'
import { api } from '../services/pmocontrol/api'
import { sse } from '../services/pmocontrol/sse'
import { ref, reactive, computed, type Ref } from "vue";
import { api } from "../services/pmocontrol/api";
import { sse } from "../services/pmocontrol/sse";
import type {
RendererSummary,
RendererState,
QueueSnapshot,
AttachedPlaylistInfo,
FullRendererSnapshot,
} from '../services/pmocontrol/types'
} from "../services/pmocontrol/types";
interface RendererSnapshotState {
snapshots: Map<string, FullRendererSnapshot>
lastSnapshotAt: Map<string, number>
lastEventAt: Map<string, number>
loadingIds: Set<string>
selectedRendererId: string | null
snapshots: Map<string, FullRendererSnapshot>;
lastSnapshotAt: Map<string, number>;
lastEventAt: Map<string, number>;
loadingIds: Set<string>;
selectedRendererId: string | null;
}
const renderersCache = ref<Map<string, RendererSummary>>(new Map())
const RENDERERS_CACHE_MS = 2000
const lastRenderersFetch = ref(0)
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
const RENDERERS_CACHE_MS = 2000;
const lastRenderersFetch = ref(0);
const snapshotState = reactive<RendererSnapshotState>({
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
@@ -33,23 +33,25 @@ const snapshotState = reactive<RendererSnapshotState>({
lastEventAt: reactive(new Map<string, number>()),
loadingIds: reactive(new Set<string>()),
selectedRendererId: null,
})
});
const loading = ref(false)
const error = ref<string | null>(null)
const loading = ref(false);
const error = ref<string | null>(null);
let sseConnected = false
let sseConnected = false;
function ensureSSEConnected() {
if (sseConnected) return
if (sseConnected) return;
sse.onRendererEvent((event) => {
const rendererId = event.renderer_id
const timestamp = Date.parse(event.timestamp ?? '') || Date.now()
const rendererId = event.renderer_id;
const timestamp = Date.parse(event.timestamp ?? "") || Date.now();
// Gérer les événements Online/Offline différemment
if (event.type === 'online') {
if (event.type === "online") {
// Nouveau renderer découvert
console.log(`[useRenderers] Renderer ${rendererId} (${event.friendly_name}) est maintenant en ligne`)
console.log(
`[useRenderers] Renderer ${rendererId} (${event.friendly_name}) est maintenant en ligne`,
);
// Ajouter au cache avec les infos disponibles
// Note: on n'a pas toutes les infos (capabilities, protocol) donc on fetch ensuite
@@ -57,7 +59,7 @@ function ensureSSEConnected() {
id: rendererId,
friendly_name: event.friendly_name,
model_name: event.model_name,
protocol: 'upnp', // Valeur par défaut, sera mise à jour par le fetch
protocol: "upnp", // Valeur par défaut, sera mise à jour par le fetch
capabilities: {
has_avtransport: false,
has_avtransport_set_next: false,
@@ -72,187 +74,200 @@ function ensureSSEConnected() {
has_oh_radio: false,
},
online: true,
}
renderersCache.value.set(rendererId, renderer)
};
renderersCache.value.set(rendererId, renderer);
// Fetch la liste complète pour avoir les bonnes infos
void fetchRenderers(true)
void fetchRenderers(true);
// Fetch le snapshot complet pour ce renderer
void fetchRendererSnapshot(rendererId, { force: true })
return
void fetchRendererSnapshot(rendererId, { force: true });
return;
}
if (event.type === 'offline') {
if (event.type === "offline") {
// Renderer déconnecté
console.log(`[useRenderers] Renderer ${rendererId} est maintenant hors ligne`)
console.log(
`[useRenderers] Renderer ${rendererId} est maintenant hors ligne`,
);
// Marquer comme offline dans le cache
const renderer = renderersCache.value.get(rendererId)
const renderer = renderersCache.value.get(rendererId);
if (renderer) {
renderer.online = false
renderersCache.value.set(rendererId, renderer)
renderer.online = false;
renderersCache.value.set(rendererId, renderer);
}
// Supprimer le snapshot (il n'est plus valide)
snapshotState.snapshots.delete(rendererId)
snapshotState.lastSnapshotAt.delete(rendererId)
snapshotState.lastEventAt.delete(rendererId)
return
snapshotState.snapshots.delete(rendererId);
snapshotState.lastSnapshotAt.delete(rendererId);
snapshotState.lastEventAt.delete(rendererId);
return;
}
// Pour les autres événements, comportement existant
snapshotState.lastEventAt.set(rendererId, timestamp)
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0
snapshotState.lastEventAt.set(rendererId, timestamp);
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0;
if (!snapshotState.snapshots.has(rendererId) || timestamp > lastSnapshot) {
void fetchRendererSnapshot(rendererId, { force: true })
void fetchRendererSnapshot(rendererId, { force: true });
}
})
});
sseConnected = true
sseConnected = true;
}
const allRenderers = computed(() => Array.from(renderersCache.value.values()))
const onlineRenderers = computed(() => allRenderers.value.filter((r) => r.online))
const allSnapshots = computed(() => Array.from(snapshotState.snapshots.values()))
const allRenderers = computed(() => Array.from(renderersCache.value.values()));
const onlineRenderers = computed(() =>
allRenderers.value.filter((r) => r.online),
);
const allSnapshots = computed(() =>
Array.from(snapshotState.snapshots.values()),
);
const playingRenderers = computed(() =>
allSnapshots.value
.filter((snapshot) => snapshot.state.transport_state === 'PLAYING')
.filter((snapshot) => snapshot.state.transport_state === "PLAYING")
.map((snapshot) => snapshot.state),
)
);
function getRendererById(id: string) {
return renderersCache.value.get(id)
return renderersCache.value.get(id);
}
function getSnapshotById(id: string) {
return snapshotState.snapshots.get(id) ?? null
return snapshotState.snapshots.get(id) ?? null;
}
function getStateById(id: string): RendererState | null {
return snapshotState.snapshots.get(id)?.state ?? null
return snapshotState.snapshots.get(id)?.state ?? null;
}
function getQueueById(id: string): QueueSnapshot | null {
return snapshotState.snapshots.get(id)?.queue ?? null
return snapshotState.snapshots.get(id)?.queue ?? null;
}
function getBindingById(id: string): AttachedPlaylistInfo | null {
return snapshotState.snapshots.get(id)?.binding ?? null
return snapshotState.snapshots.get(id)?.binding ?? null;
}
function isSnapshotLoading(id: string) {
return snapshotState.loadingIds.has(id)
return snapshotState.loadingIds.has(id);
}
function selectRenderer(id: string | null) {
snapshotState.selectedRendererId = id
snapshotState.selectedRendererId = id;
}
async function fetchRenderers(force = false) {
ensureSSEConnected()
ensureSSEConnected();
const now = Date.now()
const now = Date.now();
if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) {
return
return;
}
try {
loading.value = true
error.value = null
const data = await api.getRenderers()
renderersCache.value = new Map(data.map((renderer) => [renderer.id, renderer]))
lastRenderersFetch.value = now
loading.value = true;
error.value = null;
const data = await api.getRenderers();
renderersCache.value = new Map(
data.map((renderer) => [renderer.id, renderer]),
);
lastRenderersFetch.value = now;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Erreur fetch renderers'
console.error('[useRenderers] Erreur fetch:', err)
error.value = err instanceof Error ? err.message : "Erreur fetch renderers";
console.error("[useRenderers] Erreur fetch:", err);
} finally {
loading.value = false
loading.value = false;
}
}
async function fetchRendererSnapshot(rendererId: string, opts?: { force?: boolean }) {
ensureSSEConnected()
const force = opts?.force ?? false
const hasSnapshot = snapshotState.snapshots.has(rendererId)
async function fetchRendererSnapshot(
rendererId: string,
opts?: { force?: boolean },
) {
ensureSSEConnected();
const force = opts?.force ?? false;
const hasSnapshot = snapshotState.snapshots.has(rendererId);
if (!force && hasSnapshot) {
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0
const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0;
const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0;
if (lastEvent <= lastSnapshot) {
return
return;
}
}
if (snapshotState.loadingIds.has(rendererId)) {
return
return;
}
snapshotState.loadingIds.add(rendererId)
snapshotState.loadingIds.add(rendererId);
try {
const snapshot = await api.getRendererFullSnapshot(rendererId)
snapshotState.snapshots.set(rendererId, snapshot)
snapshotState.lastSnapshotAt.set(rendererId, Date.now())
const snapshot = await api.getRendererFullSnapshot(rendererId);
snapshotState.snapshots.set(rendererId, snapshot);
snapshotState.lastSnapshotAt.set(rendererId, Date.now());
} catch (err) {
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err)
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
} finally {
snapshotState.loadingIds.delete(rendererId)
snapshotState.loadingIds.delete(rendererId);
}
}
// Transport controls
async function play(id: string) {
await api.play(id)
await api.play(id);
}
async function resumeOrPlayFromQueue(id: string) {
const snapshot = snapshotState.snapshots.get(id)
const snapshot = snapshotState.snapshots.get(id);
if (!snapshot) {
throw new Error(`Renderer ${id} non trouvé`)
throw new Error(`Renderer ${id} non trouvé`);
}
const state = snapshot.state
if (state.transport_state === 'PAUSED') {
return play(id)
const state = snapshot.state;
if (state.transport_state === "PAUSED") {
return play(id);
}
if (
['STOPPED', 'NO_MEDIA'].includes(state.transport_state) &&
["STOPPED", "NO_MEDIA"].includes(state.transport_state) &&
snapshot.queue.items.length > 0
) {
return api.resume(id)
return api.resume(id);
}
throw new Error('La file d\'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.')
throw new Error(
"La file d'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.",
);
}
async function pause(id: string) {
await api.pause(id)
await api.pause(id);
}
async function stop(id: string) {
await api.stop(id)
await api.stop(id);
}
async function next(id: string) {
await api.next(id)
await api.next(id);
}
// Volume controls
async function setVolume(id: string, volume: number) {
await api.setVolume(id, volume)
await api.setVolume(id, volume);
}
async function volumeUp(id: string) {
await api.volumeUp(id)
await api.volumeUp(id);
}
async function volumeDown(id: string) {
await api.volumeDown(id)
await api.volumeDown(id);
}
async function toggleMute(id: string) {
await api.toggleMute(id)
await api.toggleMute(id);
}
// Playlist binding
@@ -262,11 +277,16 @@ async function attachPlaylist(
containerId: string,
options?: { autoPlay?: boolean },
) {
await api.attachPlaylist(rendererId, serverId, containerId, options?.autoPlay ?? false)
await api.attachPlaylist(
rendererId,
serverId,
containerId,
options?.autoPlay ?? false,
);
}
async function detachPlaylist(rendererId: string) {
await api.detachPlaylist(rendererId)
await api.detachPlaylist(rendererId);
}
async function attachAndPlayPlaylist(
@@ -274,20 +294,36 @@ async function attachAndPlayPlaylist(
serverId: string,
containerId: string,
) {
await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true })
await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true });
}
// Queue content
async function playContent(rendererId: string, serverId: string, objectId: string) {
await api.playContent(rendererId, serverId, objectId)
async function playContent(
rendererId: string,
serverId: string,
objectId: string,
) {
await api.playContent(rendererId, serverId, objectId);
}
async function addToQueue(rendererId: string, serverId: string, objectId: string) {
await api.addToQueue(rendererId, serverId, objectId)
async function addToQueue(
rendererId: string,
serverId: string,
objectId: string,
) {
await api.addToQueue(rendererId, serverId, objectId);
}
async function addAfterCurrent(
rendererId: string,
serverId: string,
objectId: string,
) {
await api.addAfterCurrent(rendererId, serverId, objectId);
}
export function useRenderers() {
ensureSSEConnected()
ensureSSEConnected();
return {
loading,
@@ -326,23 +362,26 @@ export function useRenderers() {
// Queue content
playContent,
addToQueue,
}
addAfterCurrent,
};
}
export function useRenderer(rendererId: Ref<string>) {
ensureSSEConnected()
ensureSSEConnected();
const renderer = computed(() => renderersCache.value.get(rendererId.value))
const snapshot = computed(() => snapshotState.snapshots.get(rendererId.value) ?? null)
const state = computed(() => snapshot.value?.state ?? null)
const queue = computed(() => snapshot.value?.queue ?? null)
const binding = computed(() => snapshot.value?.binding ?? null)
const renderer = computed(() => renderersCache.value.get(rendererId.value));
const snapshot = computed(
() => snapshotState.snapshots.get(rendererId.value) ?? null,
);
const state = computed(() => snapshot.value?.state ?? null);
const queue = computed(() => snapshot.value?.queue ?? null);
const binding = computed(() => snapshot.value?.binding ?? null);
async function refresh(force = true) {
await Promise.all([
fetchRenderers(force),
fetchRendererSnapshot(rendererId.value, { force: true }),
])
]);
}
return {
@@ -352,5 +391,5 @@ export function useRenderer(rendererId: Ref<string>) {
queue,
binding,
refresh,
}
};
}

View File

@@ -13,40 +13,40 @@ import type {
AttachPlaylistRequest,
PlayContentRequest,
SuccessResponse,
ErrorResponse
} from './types'
ErrorResponse,
} from "./types";
/**
* Client API REST pour le Control Point PMOMusic
*/
class PMOControlAPI {
private readonly baseURL = '/api/control'
private readonly baseURL = "/api/control";
/**
* Effectue une requête HTTP générique
*/
private async request<T>(
path: string,
options: RequestInit = {}
options: RequestInit = {},
): Promise<T> {
const url = `${this.baseURL}${path}`
const url = `${this.baseURL}${path}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
...options.headers,
},
})
});
if (!response.ok) {
const error: ErrorResponse = await response.json().catch(() => ({
error: `HTTP ${response.status}: ${response.statusText}`,
}))
throw new Error(error.error)
}));
throw new Error(error.error);
}
return response.json()
return response.json();
}
// ============================================================================
@@ -58,7 +58,7 @@ class PMOControlAPI {
* GET /api/control/renderers
*/
async getRenderers(): Promise<RendererSummary[]> {
return this.request<RendererSummary[]>('/renderers')
return this.request<RendererSummary[]>("/renderers");
}
/**
@@ -66,7 +66,7 @@ class PMOControlAPI {
* GET /api/control/renderers/{id}
*/
async getRendererState(id: string): Promise<RendererState> {
return this.request<RendererState>(`/renderers/${encodeURIComponent(id)}`)
return this.request<RendererState>(`/renderers/${encodeURIComponent(id)}`);
}
/**
@@ -74,7 +74,9 @@ class PMOControlAPI {
* GET /api/control/renderers/{id}/full
*/
async getRendererFullSnapshot(id: string): Promise<FullRendererSnapshot> {
return this.request<FullRendererSnapshot>(`/renderers/${encodeURIComponent(id)}/full`)
return this.request<FullRendererSnapshot>(
`/renderers/${encodeURIComponent(id)}/full`,
);
}
/**
@@ -82,7 +84,9 @@ class PMOControlAPI {
* GET /api/control/renderers/{id}/queue
*/
async getQueue(id: string): Promise<QueueSnapshot> {
return this.request<QueueSnapshot>(`/renderers/${encodeURIComponent(id)}/queue`)
return this.request<QueueSnapshot>(
`/renderers/${encodeURIComponent(id)}/queue`,
);
}
/**
@@ -90,7 +94,9 @@ class PMOControlAPI {
* GET /api/control/renderers/{id}/binding
*/
async getBinding(id: string): Promise<AttachedPlaylistInfo | null> {
return this.request<AttachedPlaylistInfo | null>(`/renderers/${encodeURIComponent(id)}/binding`)
return this.request<AttachedPlaylistInfo | null>(
`/renderers/${encodeURIComponent(id)}/binding`,
);
}
// ============================================================================
@@ -102,9 +108,12 @@ class PMOControlAPI {
* POST /api/control/renderers/{id}/play
*/
async play(id: string): Promise<SuccessResponse> {
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/play`, {
method: 'POST',
})
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(id)}/play`,
{
method: "POST",
},
);
}
/**
@@ -112,9 +121,12 @@ class PMOControlAPI {
* POST /api/control/renderers/{id}/pause
*/
async pause(id: string): Promise<SuccessResponse> {
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/pause`, {
method: 'POST',
})
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(id)}/pause`,
{
method: "POST",
},
);
}
/**
@@ -122,9 +134,12 @@ class PMOControlAPI {
* POST /api/control/renderers/{id}/stop
*/
async stop(id: string): Promise<SuccessResponse> {
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/stop`, {
method: 'POST',
})
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(id)}/stop`,
{
method: "POST",
},
);
}
/**
@@ -132,9 +147,12 @@ class PMOControlAPI {
* POST /api/control/renderers/{id}/resume
*/
async resume(id: string): Promise<SuccessResponse> {
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/resume`, {
method: 'POST',
})
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(id)}/resume`,
{
method: "POST",
},
);
}
/**
@@ -142,9 +160,12 @@ class PMOControlAPI {
* POST /api/control/renderers/{id}/next
*/
async next(id: string): Promise<SuccessResponse> {
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/next`, {
method: 'POST',
})
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(id)}/next`,
{
method: "POST",
},
);
}
/**
@@ -152,10 +173,13 @@ class PMOControlAPI {
* POST /api/control/renderers/{id}/queue/seek
*/
async seekQueueIndex(id: string, index: number): Promise<SuccessResponse> {
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/queue/seek`, {
method: 'POST',
body: JSON.stringify({ index }),
})
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(id)}/queue/seek`,
{
method: "POST",
body: JSON.stringify({ index }),
},
);
}
// ============================================================================
@@ -167,11 +191,14 @@ class PMOControlAPI {
* POST /api/control/renderers/{id}/volume/set
*/
async setVolume(id: string, volume: number): Promise<SuccessResponse> {
const payload: VolumeSetRequest = { volume }
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/set`, {
method: 'POST',
body: JSON.stringify(payload),
})
const payload: VolumeSetRequest = { volume };
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(id)}/volume/set`,
{
method: "POST",
body: JSON.stringify(payload),
},
);
}
/**
@@ -179,9 +206,12 @@ class PMOControlAPI {
* POST /api/control/renderers/{id}/volume/up
*/
async volumeUp(id: string): Promise<SuccessResponse> {
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/up`, {
method: 'POST',
})
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(id)}/volume/up`,
{
method: "POST",
},
);
}
/**
@@ -189,9 +219,12 @@ class PMOControlAPI {
* POST /api/control/renderers/{id}/volume/down
*/
async volumeDown(id: string): Promise<SuccessResponse> {
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/down`, {
method: 'POST',
})
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(id)}/volume/down`,
{
method: "POST",
},
);
}
/**
@@ -199,9 +232,12 @@ class PMOControlAPI {
* POST /api/control/renderers/{id}/mute/toggle
*/
async toggleMute(id: string): Promise<SuccessResponse> {
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/mute/toggle`, {
method: 'POST',
})
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(id)}/mute/toggle`,
{
method: "POST",
},
);
}
// ============================================================================
@@ -216,17 +252,20 @@ class PMOControlAPI {
rendererId: string,
serverId: string,
containerId: string,
autoPlay = false
autoPlay = false,
): Promise<SuccessResponse> {
const payload: AttachPlaylistRequest = {
server_id: serverId,
container_id: containerId,
auto_play: autoPlay,
}
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/binding/attach`, {
method: 'POST',
body: JSON.stringify(payload),
})
};
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(rendererId)}/binding/attach`,
{
method: "POST",
body: JSON.stringify(payload),
},
);
}
/**
@@ -234,9 +273,12 @@ class PMOControlAPI {
* POST /api/control/renderers/{id}/binding/detach
*/
async detachPlaylist(rendererId: string): Promise<SuccessResponse> {
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/binding/detach`, {
method: 'POST',
})
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(rendererId)}/binding/detach`,
{
method: "POST",
},
);
}
// ============================================================================
@@ -250,13 +292,19 @@ class PMOControlAPI {
async playContent(
rendererId: string,
serverId: string,
objectId: 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),
})
const payload: PlayContentRequest = {
server_id: serverId,
object_id: objectId,
};
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(rendererId)}/queue/play`,
{
method: "POST",
body: JSON.stringify(payload),
},
);
}
/**
@@ -266,13 +314,61 @@ class PMOControlAPI {
async addToQueue(
rendererId: string,
serverId: string,
objectId: 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),
})
const payload: PlayContentRequest = {
server_id: serverId,
object_id: objectId,
};
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(rendererId)}/queue/add`,
{
method: "POST",
body: JSON.stringify(payload),
},
);
}
/**
* Ajouter du contenu après le morceau actuel
* POST /api/control/renderers/{id}/queue/add-after
*/
async addAfterCurrent(
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-after`,
{
method: "POST",
body: JSON.stringify(payload),
},
);
}
/**
* Transfère la queue d'un renderer vers un autre
* POST /api/control/renderers/{id}/queue/transfer
*/
async transferQueue(
sourceRendererId: string,
destinationRendererId: string,
): Promise<SuccessResponse> {
const payload = {
destination_renderer_id: destinationRendererId,
};
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(sourceRendererId)}/queue/transfer`,
{
method: "POST",
body: JSON.stringify(payload),
},
);
}
// ============================================================================
@@ -284,19 +380,22 @@ class PMOControlAPI {
* GET /api/control/servers
*/
async getServers(): Promise<MediaServerSummary[]> {
return this.request<MediaServerSummary[]>('/servers')
return this.request<MediaServerSummary[]>("/servers");
}
/**
* Browse le contenu d'un container sur un serveur
* GET /api/control/servers/{serverId}/containers/{containerId}
*/
async browseContainer(serverId: string, containerId: string): Promise<BrowseResponse> {
async browseContainer(
serverId: string,
containerId: string,
): Promise<BrowseResponse> {
return this.request<BrowseResponse>(
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`
)
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`,
);
}
}
// Export singleton
export const api = new PMOControlAPI()
export const api = new PMOControlAPI();

View File

@@ -1,334 +1,346 @@
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTabs } from '@/composables/useTabs'
import { useRenderers } from '@/composables/useRenderers'
import { useMediaServers } from '@/composables/useMediaServers'
import { useSwipe } from '@vueuse/core'
import { api } from '@/services/pmocontrol/api'
import type { ContainerEntry } from '@/services/pmocontrol/types'
import { ref, watch, onMounted, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTabs } from "@/composables/useTabs";
import { useRenderers } from "@/composables/useRenderers";
import { useMediaServers } from "@/composables/useMediaServers";
import { useSwipe } from "@vueuse/core";
// Import des composants
import BottomTabBar from '@/components/unified/BottomTabBar.vue'
import EmptyState from '@/components/unified/EmptyState.vue'
import ServerDrawer from '@/components/unified/ServerDrawer.vue'
import RendererTabContent from '@/components/unified/RendererTabContent.vue'
import ServerTabContent from '@/components/unified/ServerTabContent.vue'
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";
const route = useRoute()
const router = useRouter()
const { tabs, activeTabId, switchTab, activeTab, syncWithRenderers, isEmpty } = useTabs()
const { allRenderers, fetchRenderers } = useRenderers()
const { allServers, fetchServers } = useMediaServers()
const route = useRoute();
const router = useRouter();
const { tabs, activeTabId, switchTab, activeTab, syncWithRenderers, isEmpty } =
useTabs();
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
const { allServers, fetchServers } = useMediaServers();
// État du drawer server
const drawerOpen = ref(false)
// État des drawers
const drawerOpen = ref(false);
const rendererDrawerOpen = ref(false);
// Ref pour le swipe edge detection
const viewRef = ref<HTMLElement | null>(null)
const viewRef = ref<HTMLElement | null>(null);
// Swipe depuis le bord gauche pour ouvrir le drawer
useSwipe(viewRef, {
threshold: 50,
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
// Swipe right depuis le bord gauche → ouvrir drawer
if (swipeDirection === 'right' && !drawerOpen.value) {
const touch = _e.changedTouches[0]
// Vérifier que le swipe commence depuis le bord gauche (< 50px)
if (touch && touch.clientX < 50) {
drawerOpen.value = true
}
threshold: 50,
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
// Swipe right depuis le bord gauche → ouvrir drawer
if (swipeDirection === "right" && !drawerOpen.value) {
const touch = _e.changedTouches[0];
// Vérifier que le swipe commence depuis le bord gauche (< 50px)
if (touch && touch.clientX < 50) {
drawerOpen.value = true;
}
}
},
});
// Récupérer l'ID du renderer actuellement sélectionné
const currentRendererId = computed(() => {
const currentTab = activeTab.value;
if (currentTab?.type === "renderer" && currentTab.metadata?.rendererId) {
return currentTab.metadata.rendererId;
}
},
})
return null;
});
// Gestion de la lecture d'un item depuis le drawer
async function handlePlayItem(item: ContainerEntry, serverId: string) {
const currentTab = activeTab.value
// 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;
});
if (!currentTab || currentTab.type !== 'renderer' || !currentTab.metadata?.rendererId) {
console.error('[UnifiedControlView] Pas de renderer actif')
return
}
const rendererId = currentTab.metadata.rendererId
try {
console.log('[UnifiedControlView] Play item:', item.title, 'on renderer:', rendererId)
await api.attachPlaylist(rendererId, serverId, item.id, true) // autoPlay = true
// Fermer le drawer après succès
drawerOpen.value = false
} catch (error) {
console.error('[UnifiedControlView] Erreur lors de la lecture:', error)
}
}
// Gestion de l'ajout d'un item à la queue depuis le drawer
async function handleQueueItem(item: ContainerEntry, serverId: string) {
const currentTab = activeTab.value
if (!currentTab || currentTab.type !== 'renderer' || !currentTab.metadata?.rendererId) {
console.error('[UnifiedControlView] Pas de renderer actif')
return
}
const rendererId = currentTab.metadata.rendererId
try {
console.log('[UnifiedControlView] Queue item:', item.title, 'on renderer:', rendererId)
await api.attachPlaylist(rendererId, serverId, item.id, false) // autoPlay = false
// Fermer le drawer après succès
drawerOpen.value = false
} catch (error) {
console.error('[UnifiedControlView] Erreur lors de l\'ajout à la queue:', error)
}
}
// 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)
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
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
await Promise.all([
fetchRenderers(),
fetchServers()
])
// Fetch renderers et servers au montage
await Promise.all([fetchRenderers(), fetchServers()]);
// Sync initial des tabs avec les renderers
syncWithRenderers(allRenderers.value)
// Sync initial des tabs avec les renderers
syncWithRenderers(allRenderers.value);
// Restaurer l'onglet actif depuis l'URL
const urlTabId = route.query.tab as string
if (urlTabId && tabs.value.find((t) => t.id === urlTabId)) {
switchTab(urlTabId)
}
})
// Restaurer l'onglet actif depuis l'URL
const urlTabId = route.query.tab as string;
if (urlTabId && tabs.value.find((t) => t.id === urlTabId)) {
switchTab(urlTabId);
}
});
// Watch renderers pour sync automatique des tabs
watch(
() => allRenderers.value,
(newRenderers) => {
syncWithRenderers(newRenderers)
},
{ deep: true },
)
() => allRenderers.value,
(newRenderers) => {
syncWithRenderers(newRenderers);
},
{ deep: true },
);
// Watch les changements d'URL pour changer d'onglet
watch(
() => route.query.tab,
(newTabId) => {
if (newTabId && typeof newTabId === 'string') {
const tab = tabs.value.find((t) => t.id === newTabId)
if (tab && activeTabId.value !== newTabId) {
switchTab(newTabId)
}
}
},
)
() => route.query.tab,
(newTabId) => {
if (newTabId && typeof newTabId === "string") {
const tab = tabs.value.find((t) => t.id === newTabId);
if (tab && activeTabId.value !== newTabId) {
switchTab(newTabId);
}
}
},
);
// Watch les changements d'onglet actif pour mettre à jour l'URL
watch(
() => activeTabId.value,
(newActiveTabId) => {
const currentTabId = route.query.tab as string
if (currentTabId !== newActiveTabId) {
router.replace({
query: {
...route.query,
tab: newActiveTabId,
tabs: tabs.value.map((t) => t.id).join(','),
},
})
}
},
)
() => activeTabId.value,
(newActiveTabId) => {
const currentTabId = route.query.tab as string;
if (currentTabId !== newActiveTabId) {
router.replace({
query: {
...route.query,
tab: newActiveTabId,
tabs: tabs.value.map((t) => t.id).join(","),
},
});
}
},
);
// Composant dynamique selon le type d'onglet
const currentTabComponent = computed(() => {
const tab = activeTab.value
if (!tab) return null
const tab = activeTab.value;
if (!tab) return null;
switch (tab.type) {
case 'renderer':
return RendererTabContent
case 'server':
return ServerTabContent
default:
return null
}
})
switch (tab.type) {
case "renderer":
return RendererTabContent;
case "server":
return ServerTabContent;
default:
return null;
}
});
// Props pour le composant actif
const currentTabProps = computed(() => {
const tab = activeTab.value
if (!tab || !tab.metadata) return null
const tab = activeTab.value;
if (!tab || !tab.metadata) return null;
if (tab.type === 'renderer' && tab.metadata.rendererId) {
return { rendererId: tab.metadata.rendererId }
}
if (tab.type === 'server' && tab.metadata.serverId) {
return { serverId: tab.metadata.serverId }
}
return null
})
if (tab.type === "renderer" && tab.metadata.rendererId) {
return { rendererId: tab.metadata.rendererId };
}
if (tab.type === "server" && tab.metadata.serverId) {
return { serverId: tab.metadata.serverId };
}
return null;
});
</script>
<template>
<div ref="viewRef" class="unified-control-view">
<!-- Zone de contenu -->
<main class="content-area">
<!-- État vide: aucun renderer détecté -->
<EmptyState v-if="isEmpty" />
<div ref="viewRef" class="unified-control-view">
<!-- Zone de contenu -->
<main class="content-area">
<!-- État vide: aucun renderer détecté -->
<EmptyState v-if="isEmpty" />
<!-- Onglets renderers/servers avec keep-alive -->
<keep-alive v-else :max="12">
<component
v-if="currentTabComponent && currentTabProps"
:is="currentTabComponent"
:key="activeTab?.id"
v-bind="currentTabProps"
<!-- Onglets renderers/servers avec keep-alive -->
<keep-alive v-else :max="12">
<component
v-if="currentTabComponent && currentTabProps"
:is="currentTabComponent"
:key="activeTab?.id"
v-bind="currentTabProps"
/>
</keep-alive>
</main>
<!-- 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"
/>
</keep-alive>
</main>
<!-- Barre d'onglets en bas -->
<BottomTabBar :online-servers-count="onlineServersCount" @open-drawer="handleDrawerOpen" />
<!-- Drawer servers (swipe depuis bord gauche) -->
<ServerDrawer
v-model="drawerOpen"
:selected-renderer-id="currentRendererId"
/>
<!-- Drawer servers (swipe depuis bord gauche) -->
<ServerDrawer
v-model="drawerOpen"
@play-item="handlePlayItem"
@queue-item="handleQueueItem"
/>
</div>
<!-- Drawer renderers (depuis bord droit) -->
<RendererDrawer
v-model="rendererDrawerOpen"
:selected-renderer-id="currentRendererId"
@select-renderer="handleRendererSelect"
/>
</div>
</template>
<style scoped>
.unified-control-view {
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
overflow: hidden;
background: var(--color-bg);
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
overflow: hidden;
background: var(--color-bg);
}
.content-area {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
padding-bottom: 80px; /* Espace pour la barre fixe en bas (64px + marge) */
position: relative;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
position: relative;
}
/* Placeholder temporaire */
.tab-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: var(--spacing-xl);
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: var(--spacing-xl);
}
.placeholder-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-lg);
max-width: 500px;
padding: var(--spacing-xl);
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-lg);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-lg);
max-width: 500px;
padding: var(--spacing-xl);
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-lg);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
text-align: center;
}
.placeholder-icon {
color: var(--color-primary);
opacity: 0.5;
color: var(--color-primary);
opacity: 0.5;
}
.placeholder-title {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin: 0;
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin: 0;
}
.placeholder-text {
font-size: var(--text-base);
color: var(--color-text-secondary);
margin: 0;
line-height: 1.6;
font-size: var(--text-base);
color: var(--color-text-secondary);
margin: 0;
line-height: 1.6;
}
.placeholder-subtitle {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
font-style: italic;
margin: 0;
font-size: var(--text-sm);
color: var(--color-text-tertiary);
font-style: italic;
margin: 0;
}
/* Responsive pour 800x600 landscape */
@media (min-width: 600px) and (orientation: landscape) {
.content-area {
padding: var(--spacing-md);
}
.content-area {
padding: var(--spacing-md);
}
}
/* Responsive mobile portrait */
@media (max-width: 768px) and (orientation: portrait) {
.content-area {
padding: var(--spacing-sm);
}
.content-area {
padding: var(--spacing-sm);
}
.placeholder-card {
padding: var(--spacing-lg);
}
.placeholder-card {
padding: var(--spacing-lg);
}
.placeholder-icon {
width: 48px;
height: 48px;
}
.placeholder-icon {
width: 48px;
height: 48px;
}
.placeholder-title {
font-size: var(--text-xl);
}
.placeholder-title {
font-size: var(--text-xl);
}
.placeholder-text {
font-size: var(--text-sm);
}
.placeholder-text {
font-size: var(--text-sm);
}
}
/* Animation de transition des onglets */
.v-enter-active,
.v-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.v-enter-from {
opacity: 0;
transform: translateX(20px);
opacity: 0;
transform: translateX(20px);
}
.v-leave-to {
opacity: 0;
transform: translateX(-20px);
opacity: 0;
transform: translateX(-20px);
}
</style>

View File

@@ -588,6 +588,19 @@ impl ControlPoint {
&self,
renderer_id: &DeviceId,
items: Vec<PlaybackItem>,
) -> Result<(), ControlPointError> {
self.enqueue_items_with_mode(renderer_id, items, EnqueueMode::AppendToEnd)
}
/// Enqueue items to a renderer's queue with a specific enqueue mode.
///
/// This is the low-level version that allows specifying the enqueue mode.
/// User-driven operations should detach any playlist binding.
pub fn enqueue_items_with_mode(
&self,
renderer_id: &DeviceId,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
// User-driven mutation: detach any playlist binding
self.detach_playlist_binding(renderer_id, "enqueue_items");
@@ -600,7 +613,7 @@ impl ControlPoint {
let new_len = {
let mut queue = renderer.get_queue_mut();
queue.enqueue_items(items, EnqueueMode::AppendToEnd)?;
queue.enqueue_items(items, mode)?;
queue.upcoming_len()?
};
@@ -608,6 +621,7 @@ impl ControlPoint {
renderer = renderer_id.0.as_str(),
added = item_count,
queue_len = new_len,
mode = ?mode,
"Enqueued playback items"
);
@@ -855,7 +869,7 @@ impl ControlPoint {
})?;
// Use generic queue access (works for all backends)
let Some((item, remaining)) = renderer.get_queue().peek_current()? else {
let Some((item, remaining)) = renderer.get_queue_mut().peek_current()? else {
debug!(
renderer = renderer_id.0.as_str(),
"play_current_from_queue: queue is empty or no current item"
@@ -976,7 +990,7 @@ impl ControlPoint {
}
// Get the next item from the queue using peek_current
let Ok(Some((_, remaining))) = renderer.get_queue().peek_current() else {
let Ok(Some((_, remaining))) = renderer.get_queue_mut().peek_current() else {
return;
};
@@ -1022,7 +1036,7 @@ impl ControlPoint {
// Set queue index
renderer.get_queue_mut().set_index(Some(index))?;
let Some((item, remaining)) = renderer.get_queue().peek_current()? else {
let Some((item, remaining)) = renderer.get_queue_mut().peek_current()? else {
debug!(
renderer = renderer_id.0.as_str(),
index, "play_queue_index: no item at index"
@@ -1230,6 +1244,109 @@ impl ControlPoint {
self.detach_playlist_binding(renderer_id, "api_detach");
}
/// Transfers the queue and playlist binding from one renderer to another.
///
/// This method performs a complete transfer:
/// 1. Takes a snapshot of the source renderer's queue (including playlist binding)
/// 2. Clears the destination renderer's queue
/// 3. Fills the destination renderer's queue with the source snapshot
/// 4. If the source had a playlist binding, recreates it on the destination
/// 5. Stops playback on the source renderer
/// 6. Starts playback on the destination renderer at the same position
/// 7. Clears the source renderer's queue
///
/// This is useful for seamlessly moving playback from one device to another
/// while preserving the queue state and playlist synchronization.
pub fn transfer_queue(
&self,
source_renderer_id: &DeviceId,
dest_renderer_id: &DeviceId,
) -> Result<(), ControlPointError> {
// 1. Get snapshot from source renderer
let source_snapshot = self.get_renderer_queue_snapshot(source_renderer_id)?;
let source_binding = self.current_queue_playlist_binding(source_renderer_id);
tracing::info!(
source = source_renderer_id.0.as_str(),
dest = dest_renderer_id.0.as_str(),
items = source_snapshot.items.len(),
current_index = ?source_snapshot.current_index,
has_binding = source_binding.is_some(),
"Transferring queue between renderers"
);
// 2. Clear destination queue
self.clear_renderer_queue(dest_renderer_id)?;
// 3. Fill destination queue with source items
let dest_renderer = self.music_renderer_by_id(dest_renderer_id).ok_or_else(|| {
ControlPointError::SnapshotError(format!(
"Destination renderer {} not found",
dest_renderer_id.0
))
})?;
dest_renderer
.replace_queue(source_snapshot.items.clone(), source_snapshot.current_index)?;
// 4. Recreate playlist binding on destination if source had one
if let Some((server_id, container_id, _)) = source_binding {
tracing::debug!(
dest = dest_renderer_id.0.as_str(),
server = server_id.0.as_str(),
container = container_id.as_str(),
"Recreating playlist binding on destination renderer"
);
self.attach_queue_to_playlist(dest_renderer_id, server_id, container_id)?;
}
// 5. Stop playback on source renderer
let source_renderer = self
.music_renderer_by_id(source_renderer_id)
.ok_or_else(|| {
ControlPointError::SnapshotError(format!(
"Source renderer {} not found",
source_renderer_id.0
))
})?;
if let Err(e) = source_renderer.stop() {
tracing::warn!(
source = source_renderer_id.0.as_str(),
error = ?e,
"Failed to stop source renderer (continuing transfer)"
);
}
// 6. Start playback on destination renderer (if there was a current item)
if source_snapshot.current_index.is_some() && !source_snapshot.items.is_empty() {
if let Err(e) = dest_renderer.play() {
tracing::warn!(
dest = dest_renderer_id.0.as_str(),
error = ?e,
"Failed to start playback on destination renderer"
);
}
}
// 7. Clear source queue
if let Err(e) = self.clear_renderer_queue(source_renderer_id) {
tracing::warn!(
source = source_renderer_id.0.as_str(),
error = ?e,
"Failed to clear source renderer queue after transfer"
);
}
tracing::info!(
source = source_renderer_id.0.as_str(),
dest = dest_renderer_id.0.as_str(),
"Queue transfer completed successfully"
);
Ok(())
}
/// Query the current playlist binding for a renderer's queue, if any.
///
/// Returns `(server_id, container_id, has_seen_update)` if the queue is

View File

@@ -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,

View File

@@ -206,10 +206,24 @@ impl MusicRenderer {
/// Get the current queue snapshot.
pub fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
self.queue
let mut snapshot = self
.queue
.lock()
.expect("Queue mutex poisoned")
.queue_snapshot()
.queue_snapshot()?;
// Enrich snapshot with playlist_id from binding if available
if let Some(binding) = self
.playlist_binding
.lock()
.expect("Binding mutex poisoned")
.as_ref()
{
// Use container_id as the playlist identifier
snapshot.playlist_id = Some(binding.container_id.clone());
}
Ok(snapshot)
}
/// Get the current queue item without advancing.
@@ -231,7 +245,7 @@ impl MusicRenderer {
/// Play the current item from the queue.
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
let queue = self.queue.lock().expect("Queue mutex poisoned");
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
let backend = self.backend.lock().expect("Backend mutex poisoned");
// Get the current item from the queue
@@ -497,6 +511,17 @@ impl MusicRenderer {
queue.clear_queue()
}
/// Replaces the entire queue with new items and sets the current index.
/// This is a complete replacement, unlike sync_queue which tries to preserve the current track.
pub fn replace_queue(
&self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
queue.replace_queue(items, current_index)
}
/// Adds a track to the queue.
///
/// This is primarily for backends with persistent queues (OpenHome).

View File

@@ -266,6 +266,14 @@ pub struct SeekQueueRequest {
pub index: usize,
}
/// Requête pour transférer une queue d'un renderer vers un autre
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct TransferQueueRequest {
/// ID du renderer de destination
pub destination_renderer_id: String,
}
/// Réponse générique de succès
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -413,6 +421,7 @@ GET /control/servers/{server_id}/containers/{container_id}
crate::pmoserver_ext::detach_playlist_binding,
crate::pmoserver_ext::play_content,
crate::pmoserver_ext::add_to_queue,
crate::pmoserver_ext::transfer_queue,
crate::pmoserver_ext::list_servers,
crate::pmoserver_ext::browse_container,
crate::sse::all_events_sse,
@@ -436,6 +445,7 @@ GET /control/servers/{server_id}/containers/{container_id}
AttachPlaylistRequest,
PlayContentRequest,
SeekQueueRequest,
TransferQueueRequest,
SuccessResponse,
ErrorResponse,
)),

View File

@@ -14,7 +14,7 @@ use crate::openapi::{
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot,
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
SeekQueueRequest, SuccessResponse, VolumeSetRequest,
SeekQueueRequest, SuccessResponse, TransferQueueRequest, VolumeSetRequest,
};
#[cfg(feature = "pmoserver")]
use crate::queue::PlaybackItem;
@@ -91,22 +91,29 @@ 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
.into_iter()
.map(|r| {
let info = r.info();
RendererSummary {
id: r.id().0.clone(),
friendly_name: r.friendly_name().to_string(),
model_name: r.model_name().to_string(),
protocol: protocol_summary(&info.protocol()),
capabilities: capability_summary(&info.capabilities()),
online: r.is_online(),
}
})
.collect();
renderers
.into_iter()
.map(|r| {
let info = r.info();
RendererSummary {
id: r.id().0.clone(),
friendly_name: r.friendly_name().to_string(),
model_name: r.model_name().to_string(),
protocol: protocol_summary(&info.protocol()),
capabilities: capability_summary(&info.capabilities()),
online: r.is_online(),
}
})
.collect::<Vec<_>>()
})
.await
.unwrap_or_default();
Json(summaries)
}
@@ -156,10 +163,22 @@ 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)
.map_err(|err| map_snapshot_error(renderer_id, err))?;
// 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))
}
@@ -1392,6 +1411,194 @@ async fn add_to_queue(
}))
}
/// POST /control/renderers/{renderer_id}/queue/add-after - Ajouter du contenu après le morceau actuel
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
path = "/renderers/{renderer_id}/queue/add-after",
params(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
request_body = PlayContentRequest,
responses(
(status = 200, description = "Contenu ajouté après le morceau actuel", body = SuccessResponse),
(status = 404, description = "Renderer ou serveur non trouvé", body = ErrorResponse),
(status = 504, description = "Timeout de la commande", body = ErrorResponse),
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
),
tag = "control"
)]
async fn add_after_current(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
Json(req): Json<PlayContentRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = DeviceId(renderer_id.clone());
let sid = DeviceId(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"));
}
// Insert items after current using the new method
control_point.enqueue_items_with_mode(
&rid,
items,
crate::queue::EnqueueMode::InsertAfterCurrent,
)?;
Ok::<(), anyhow::Error>(())
});
time::timeout(QUEUE_COMMAND_TIMEOUT, add_task)
.await
.map_err(|_| {
warn!(
"Add after current command for renderer {} exceeded {:?}",
renderer_id, QUEUE_COMMAND_TIMEOUT
);
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorResponse {
error: format!(
"Add after current timed out after {}s",
QUEUE_COMMAND_TIMEOUT.as_secs()
),
}),
)
})?
.map_err(|e| {
warn!("Task join error during add after current: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Internal task error: {}", e),
}),
)
})?
.map_err(|e| {
warn!(
"Failed to add content after current for renderer {}: {}",
renderer_id, e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to add after current: {}", e),
}),
)
})?;
debug!(
renderer = renderer_id.as_str(),
server = req.server_id.as_str(),
object = object_id_for_log.as_str(),
"Content added after current via HTTP API"
);
Ok(Json(SuccessResponse {
message: "Content added after current track".to_string(),
}))
}
/// POST /control/renderers/{renderer_id}/queue/transfer - Transfère la queue vers un autre renderer
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
path = "/renderers/{renderer_id}/queue/transfer",
params(
("renderer_id" = String, Path, description = "ID du renderer source")
),
request_body = TransferQueueRequest,
responses(
(status = 200, description = "Queue transférée avec succès", body = SuccessResponse),
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
(status = 500, description = "Erreur lors du transfert", body = ErrorResponse)
),
tag = "control"
)]
async fn transfer_queue(
State(state): State<ControlPointState>,
Path(source_renderer_id): Path<String>,
Json(req): Json<TransferQueueRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let source_id = DeviceId(source_renderer_id.clone());
let dest_id = DeviceId(req.destination_renderer_id.clone());
debug!(
source = source_renderer_id.as_str(),
dest = req.destination_renderer_id.as_str(),
"Transferring queue between renderers via HTTP API"
);
let control_point = state.control_point.clone();
tokio::task::spawn_blocking(move || control_point.transfer_queue(&source_id, &dest_id))
.await
.map_err(|e| {
warn!(
source = source_renderer_id.as_str(),
dest = req.destination_renderer_id.as_str(),
error = ?e,
"Failed to spawn transfer_queue task"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to spawn transfer task: {}", e),
}),
)
})?
.map_err(|e| {
warn!(
source = source_renderer_id.as_str(),
dest = req.destination_renderer_id.as_str(),
error = ?e,
"Failed to transfer queue"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to transfer queue: {}", e),
}),
)
})?;
debug!(
source = source_renderer_id.as_str(),
dest = req.destination_renderer_id.as_str(),
"Queue transferred successfully via HTTP API"
);
Ok(Json(SuccessResponse {
message: format!(
"Queue transferred from {} to {}",
source_renderer_id, req.destination_renderer_id
),
}))
}
// ============================================================================
// HANDLERS - MEDIA SERVERS
// ============================================================================
@@ -1407,17 +1614,23 @@ async fn add_to_queue(
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
.into_iter()
.map(|s| MediaServerSummary {
id: s.id().0.clone(),
friendly_name: s.friendly_name().to_string(),
model_name: s.model_name().to_string(),
online: s.is_online(),
})
.collect();
servers
.into_iter()
.map(|s| MediaServerSummary {
id: s.id().0.clone(),
friendly_name: s.friendly_name().to_string(),
model_name: s.model_name().to_string(),
online: s.is_online(),
})
.collect::<Vec<_>>()
})
.await
.unwrap_or_default();
Json(summaries)
}
@@ -1703,6 +1916,14 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
// Queue content
.route("/renderers/{renderer_id}/queue/play", post(play_content))
.route("/renderers/{renderer_id}/queue/add", post(add_to_queue))
.route(
"/renderers/{renderer_id}/queue/add-after",
post(add_after_current),
)
.route(
"/renderers/{renderer_id}/queue/transfer",
post(transfer_queue),
)
// Servers
.route("/servers", get(list_servers))
.route(

View File

@@ -38,7 +38,7 @@ use crate::{PlaybackItem, QueueSnapshot, errors::ControlPointError};
pub enum EnqueueMode {
/// Append new items at the end of the queue.
AppendToEnd,
/// Insert new items immediately after the current index
/// Insert new items immediately after the current playing index
/// (or at the beginning if there is no current index).
InsertAfterCurrent,
/// Replace the whole queue with the new items.
@@ -161,11 +161,12 @@ pub trait QueueBackend {
/// Returns the current item (or the first pending item if no index is set)
/// along with the count of remaining items.
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
let snapshot = self.queue_snapshot()?;
let QueueSnapshot {
items,
current_index,
..
} = snapshot;
if items.is_empty() {
@@ -198,6 +199,7 @@ pub trait QueueBackend {
let QueueSnapshot {
items,
current_index,
..
} = snapshot;
if items.is_empty() {

View File

@@ -15,7 +15,6 @@
//! - maintains a `current_index`,
//! - never starts playback (transport control is handled elsewhere).
use crate::{
DeviceId, DeviceIdentity, RendererInfo,
errors::ControlPointError,
@@ -72,27 +71,16 @@ impl QueueBackend for InternalQueue {
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
u32::try_from(id).map_err(|_| {
ControlPointError::QueueError(format!(
"Position {} exceeds u32::MAX",
id
))
})
u32::try_from(id)
.map_err(|_| ControlPointError::QueueError(format!("Position {} exceeds u32::MAX", id)))
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
match self.current_index {
None => Ok(None),
Some(i) => {
u32::try_from(i)
.map(Some)
.map_err(|_| {
ControlPointError::QueueError(format!(
"Current index {} exceeds u32::MAX",
i
))
})
}
Some(i) => u32::try_from(i).map(Some).map_err(|_| {
ControlPointError::QueueError(format!("Current index {} exceeds u32::MAX", i))
}),
}
}
@@ -109,6 +97,7 @@ impl QueueBackend for InternalQueue {
Ok(QueueSnapshot {
items,
current_index: self.current_index,
playlist_id: None,
})
}
@@ -142,16 +131,14 @@ impl QueueBackend for InternalQueue {
Ok(())
}
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>
) -> Result<(), ControlPointError> {
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
if items.is_empty() {
return self.replace_queue(Vec::new(), None);
}
// Récupérer l'item actuel
let current = self.current_index
let current = self
.current_index
.and_then(|idx| self.items.get(idx).map(|item| (idx, item.uri.clone())));
if let Some((_current_idx, current_uri)) = current {
@@ -187,7 +174,8 @@ impl QueueBackend for InternalQueue {
self.items.extend(items);
}
EnqueueMode::InsertAfterCurrent => {
let insert_pos = self.current_index
let insert_pos = self
.current_index
.map(|i| (i + 1).min(self.items.len()))
.unwrap_or(0);
@@ -242,7 +230,7 @@ impl QueueBackend for InternalQueue {
Ok(items)
}
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
if self.items.is_empty() {
return Ok(None);
}
@@ -250,7 +238,11 @@ impl QueueBackend for InternalQueue {
let len = self.items.len();
let (item, resolved_index) = match self.current_index {
Some(idx) if idx < len => (self.items.get(idx).cloned(), Some(idx)),
_ => (self.items.first().cloned(), None),
_ => {
// Si current_index est None ou invalide, initialiser à 0
self.current_index = Some(0);
(self.items.first().cloned(), Some(0))
}
};
let item = match item {

View File

@@ -156,7 +156,7 @@ impl QueueBackend for MusicQueue {
}
}
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.peek_current(),
MusicQueue::OpenHome(q) => q.peek_current(),

View File

@@ -549,6 +549,7 @@ impl QueueBackend for OpenHomeQueue {
Ok(QueueSnapshot {
items: items,
current_index: current_index,
playlist_id: None,
})
}
@@ -778,7 +779,7 @@ impl QueueBackend for OpenHomeQueue {
}
/// Optimized peek_current: use primitives instead of full snapshot.
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
let len = self.len()?;
if len == 0 {
return Ok(None);

View File

@@ -63,7 +63,6 @@ impl PlaybackItem {
}
}
/// Logical snapshot of a renderer queue.
///
/// This is the canonical view used by the ControlPoint and the REST/API
@@ -76,6 +75,10 @@ pub struct QueueSnapshot {
/// Index (0-based) of the current item in `items`, or `None` if
/// no item is currently selected.
pub current_index: Option<usize>,
/// Optional playlist ID if the queue is bound to a specific playlist.
/// This allows reconstructing a queue from a snapshot by referencing
/// the source playlist, enabling transfer between renderers.
pub playlist_id: Option<String>,
}
impl QueueSnapshot {

View File

@@ -75,7 +75,6 @@ impl ParadiseStreamingExt for pmoserver::Server {
// Récupérer ou initialiser les caches singletons
info!("📦 Getting cache singletons...");
tracing::warn!("🔍 DEBUG: About to check get_cover_cache()");
let cover_cache = match get_cover_cache() {
Some(cache) => {
info!(" ✅ Using existing cover cache singleton");
@@ -83,17 +82,14 @@ impl ParadiseStreamingExt for pmoserver::Server {
}
None => {
info!(" 📦 Initializing new cover cache singleton");
tracing::warn!("🔍 DEBUG: About to call init_cover_cache_configured()");
let cache = self
.init_cover_cache_configured()
.await
.context("Failed to initialize cover cache")?;
tracing::warn!("🔍 DEBUG: init_cover_cache_configured() completed");
register_cover_cache(cache.clone());
cache
}
};
tracing::warn!("🔍 DEBUG: Cover cache ready, checking audio cache...");
let audio_cache = match get_audio_cache() {
Some(cache) => {
@@ -104,18 +100,15 @@ impl ParadiseStreamingExt for pmoserver::Server {
}
None => {
info!(" 📦 Initializing new audio cache singleton");
tracing::warn!("🔍 DEBUG: About to call init_audio_cache_configured()");
let cache = self
.init_audio_cache_configured()
.await
.context("Failed to initialize audio cache")?;
tracing::warn!("🔍 DEBUG: init_audio_cache_configured() completed");
register_audio_cache(cache.clone());
register_playlist_audio_cache(cache.clone());
cache
}
};
tracing::warn!("🔍 DEBUG: Audio cache ready, creating ParadiseChannelManager...");
// Créer le builder d'historique
let mut history_builder = ParadiseHistoryBuilder::default();

View File

@@ -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);
}

View File

@@ -250,26 +250,19 @@ impl UpnpServerExt for Server {
}
// Enregistrer les URLs dans le serveur web
tracing::warn!("🔍 DEBUG: About to register_urls for device {}", di.udn());
di.register_urls(self).await?;
tracing::warn!("🔍 DEBUG: register_urls completed for device {}", di.udn());
// Ajouter au registre pour l'introspection
tracing::warn!("🔍 DEBUG: Adding to DEVICE_REGISTRY...");
DEVICE_REGISTRY
.write()
.unwrap()
.register(di.clone())
.map_err(|e| DeviceError::UrlRegistrationError(e))?;
tracing::warn!("🔍 DEBUG: Added to DEVICE_REGISTRY");
// Annoncer via SSDP (si initialisé)
tracing::warn!("🔍 DEBUG: Checking SSDP...");
if self.ssdp_enabled() {
tracing::warn!("🔍 DEBUG: SSDP enabled, getting lock...");
let ssdp_opt = SSDP_SERVER.read().unwrap();
if let Some(ref ssdp) = *ssdp_opt {
tracing::warn!("🔍 DEBUG: SSDP server exists, announcing...");
use crate::config_ext::UpnpConfigExt;
let config = pmoconfig::get_config();
let manufacturer = config
@@ -278,10 +271,8 @@ impl UpnpServerExt for Server {
let ssdp_device = di.to_ssdp_device(&manufacturer, "1.0");
ssdp.add_device(ssdp_device);
info!("✅ SSDP announcement for {}", di.udn());
tracing::warn!("🔍 DEBUG: SSDP announcement complete");
}
}
tracing::warn!("🔍 DEBUG: Returning device instance");
Ok(di)
}