Files
pmomusic/pmoapp/webapp/src/components/unified/RendererDrawer.vue

904 lines
27 KiB
Vue
Raw Normal View History

<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 { useWebRenderer } from "@/composables/useWebRenderer";
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 webRenderer = useWebRenderer();
const router = useRouter();
// Filtre pour n'afficher que notre WebRenderer (pas les autres onglets/navigateurs)
const myUdn = computed(() => webRenderer.rendererUdn.value);
const filteredRenderers = computed(() => {
const udn = myUdn.value;
const normalizedUdn = udn?.replace(/^uuid:/, '') ?? null;
return allRenderers.value.filter((r: RendererSummary) => {
// WebRenderers have "Web Audio " as friendly_name prefix
const isWebRenderer = r.friendly_name?.startsWith("Web Audio ") ?? false;
if (isWebRenderer) {
if (normalizedUdn === null) return false;
const normalizedId = r.id.replace(/^uuid:/, '');
return normalizedId === normalizedUdn;
}
return true;
});
});
// 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(() =>
filteredRenderers.value.filter((r: RendererSummary) => r.online),
);
const offlineRenderers = computed(() =>
filteredRenderers.value.filter((r: RendererSummary) => !r.online),
);
// Noms de renderers qui apparaissent plus d'une fois (online + offline)
const duplicateNames = computed(() => {
const counts = new Map<string, number>();
for (const r of filteredRenderers.value) {
counts.set(r.friendly_name, (counts.get(r.friendly_name) ?? 0) + 1);
}
return new Set([...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name));
});
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");
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="app-drawer 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,
)
}}{{ duplicateNames.has(renderer.friendly_name) && renderer.server_host ? ` [${renderer.server_host}]` : '' }}
</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 }}{{ duplicateNames.has(renderer.friendly_name) && renderer.server_host ? ` [${renderer.server_host}]` : '' }}
</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 {
display: none; /* Mobile portrait: le drawer couvre 100vw, pas besoin de backdrop */
}
}
/* Positionnement spécifique — le reste est dans drawers.css (.app-drawer) */
.renderer-drawer {
right: 0;
border-left: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.4);
}
@media (prefers-color-scheme: light) {
.renderer-drawer {
border-left-color: rgba(0, 0, 0, 0.1);
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.15);
}
}
/* .drawer-header, .drawer-title, .drawer-close-btn, .drawer-content définis dans drawers.css */
.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 classes (.protocol-badge, .protocol-upnp, etc.) are now global in pmocontrol.css */
/* 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: rgba(20, 20, 30, 0.98);
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>