push-yosuotuuuukv #42
@@ -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") {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
977
pmoapp/webapp/src/components/unified/RendererDrawer.vue
Normal file
977
pmoapp/webapp/src/components/unified/RendererDrawer.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
)),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user