Files
pmomusic/pmoapp/webapp/src/components/unified/ServerDrawer.vue
Eric Coissac d810e99a5b fix: improve search clearing and bump version to 0.3.53
Make `handleClearSearch` async and reset navigation to the root container. Explicitly clear the search input across menu clear, server switch, and disconnect transitions to ensure consistent UI synchronization and prevent stale queries. Also bump the project version from 0.3.52 to 0.3.53 in Cargo.toml and version.txt.
2026-06-14 09:10:27 +02:00

1459 lines
43 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, reactive, onMounted, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";
import {
X,
Server as ServerIcon,
Circle,
ChevronLeft,
Folder,
Music,
ChevronRight,
Play,
Plus,
Settings,
MoreVertical,
} from "lucide-vue-next";
import { useMediaServers } from "@/composables/useMediaServers";
import { useRenderers } from "@/composables/useRenderers";
import type {
MediaServerSummary,
ContainerEntry,
} from "@/services/pmocontrol/types";
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];
}>();
const {
allServers,
fetchServers,
browseContainer,
getBrowseCached,
loadMoreBrowse,
hasMore,
loadingMore,
currentPath,
setPath,
clearPath,
searchServer,
} = useMediaServers();
const searchInput = ref('');
async function handleSearch() {
console.log('[ServerDrawer] handleSearch called, currentServer:', currentServer.value?.id, 'searchInput:', searchInput.value);
if (!currentServer.value || !searchInput.value.trim()) return;
const query = searchInput.value.trim();
isLoading.value = true;
try {
const virtualId = await searchServer(currentServer.value.id, query);
if (!virtualId) return;
currentContainerId.value = virtualId;
setPath([
{ id: '0', title: currentServer.value.friendly_name },
{ id: virtualId, title: `Recherche : ${query}` },
]);
} finally {
isLoading.value = false;
}
}
async function handleClearSearch() {
searchInput.value = '';
if (!currentServer.value) return;
isLoading.value = true;
try {
await browseContainer(currentServer.value.id, "0");
currentContainerId.value = "0";
setPath([{ id: "0", title: currentServer.value.friendly_name }]);
} catch (error) {
console.error("[ServerDrawer] Erreur navigation racine après clear search:", error);
} finally {
isLoading.value = false;
}
}
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
useRenderers();
const router = useRouter();
// État de navigation
const currentServer = ref<MediaServerSummary | null>(null);
const currentContainerId = ref<string | null>(null);
const browseData = computed(() =>
currentServer.value && currentContainerId.value
? (getBrowseCached(currentServer.value.id, currentContainerId.value) ?? null)
: null,
);
const isLoading = ref(false);
// Infinite scroll
const sentinelRef = ref<HTMLElement | null>(null);
const drawerContentRef = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const canLoadMore = computed(() =>
currentServer.value && currentContainerId.value
? hasMore(currentServer.value.id, currentContainerId.value)
: false,
);
function setupObserver() {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && canLoadMore.value && !loadingMore.value) {
if (currentServer.value && currentContainerId.value) {
loadMoreBrowse(currentServer.value.id, currentContainerId.value);
}
}
},
{ root: drawerContentRef.value, threshold: 0.1 },
);
if (sentinelRef.value) observer.observe(sentinelRef.value);
}
onMounted(() => setupObserver());
onBeforeUnmount(() => observer?.disconnect());
watch(sentinelRef, (el) => {
if (el) setupObserver();
});
watch(drawerContentRef, (el) => {
if (el) setupObserver();
});
// Recharger quand le cache est invalidé par un événement SSE (containers_updated / global_updated)
watch(browseData, async (data, oldData) => {
if (!data && oldData && currentServer.value && currentContainerId.value && !isLoading.value) {
isLoading.value = true;
try {
await browseContainer(currentServer.value.id, currentContainerId.value, false);
} catch (error) {
console.error("[ServerDrawer] Erreur reload après invalidation cache:", error);
} finally {
isLoading.value = false;
}
}
});
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)
const openMenuId = ref<string | null>(null);
// Track image loading state per item
const imageStates = reactive<Map<string, { loaded: boolean; error: boolean }>>(
new Map(),
);
function getImageState(itemId: string) {
if (!imageStates.has(itemId)) {
imageStates.set(itemId, { loaded: false, error: false });
}
return imageStates.get(itemId)!;
}
function handleImageLoad(itemId: string) {
const state = getImageState(itemId);
state.loaded = true;
state.error = false;
}
function handleImageError(itemId: string) {
const state = getImageState(itemId);
state.error = true;
}
// Rafraîchir la liste quand le drawer s'ouvre
watch(
() => props.modelValue,
(isOpen) => {
if (isOpen) {
fetchServers();
} else {
// Reset navigation quand on ferme
currentServer.value = null;
currentContainerId.value = null;
clearPath();
closeMenu();
imageStates.clear();
searchInput.value = '';
}
},
);
// 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);
}
},
);
const onlineServers = computed(() =>
allServers.value.filter((s: MediaServerSummary) => s.online),
);
const offlineServers = computed(() =>
allServers.value.filter((s: MediaServerSummary) => !s.online),
);
// Mode du drawer
const isNavigating = computed(() => currentServer.value !== null);
function close() {
emit("update:modelValue", false);
}
async function handleServerClick(server: MediaServerSummary) {
if (!server.online) return;
// Commencer la navigation dans ce serveur
currentServer.value = server;
searchInput.value = '';
isLoading.value = true;
try {
// Browse racine (containerId = "0")
await browseContainer(server.id, "0");
currentContainerId.value = "0";
setPath([{ id: "0", title: server.friendly_name }]);
} catch (error) {
console.error("[ServerDrawer] Erreur browse racine:", error);
currentContainerId.value = null;
} finally {
isLoading.value = false;
}
}
function goBack() {
currentServer.value = null;
currentContainerId.value = null;
clearPath();
searchInput.value = '';
}
async function handleContainerClick(item: ContainerEntry) {
if (!currentServer.value) return;
isLoading.value = true;
try {
await browseContainer(currentServer.value.id, item.id);
currentContainerId.value = item.id;
setPath([...currentPath.value, { id: item.id, title: item.title }]);
} catch (error) {
console.error("[ServerDrawer] Erreur browse container:", error);
} finally {
isLoading.value = false;
}
}
async function handleBreadcrumbClick(index: number) {
if (!currentServer.value || index >= currentPath.value.length) return;
const targetCrumb = currentPath.value[index];
if (!targetCrumb) return;
isLoading.value = true;
try {
await browseContainer(currentServer.value.id, targetCrumb.id);
currentContainerId.value = targetCrumb.id;
setPath(currentPath.value.slice(0, index + 1));
} catch (error) {
console.error("[ServerDrawer] Erreur breadcrumb navigation:", error);
} finally {
isLoading.value = false;
}
}
// Détermine si un item est jouable (track ou container jouable comme album/playlist)
function isPlayable(item: ContainerEntry): boolean {
if (!item.is_container) return true; // Les tracks sont toujours jouables
// Containers jouables basés sur la classe UPnP
const playableClasses = [
"object.container.album",
"object.container.playlist",
"object.container.person.musicartist",
];
return playableClasses.some((cls) =>
item.class.toLowerCase().includes(cls),
);
}
// Détermine si un container est navigable
function isNavigable(item: ContainerEntry): boolean {
if (!item.is_container) return false;
// Une playlist avec un seul item : on garde le caractère jouable mais on retire
// la navigation pour éviter que l'utilisateur joue l'item directement et perde
// le bénéfice des mises à jour de métadonnées de la playlist.
if (isPlayable(item) && item.child_count === 1) return false;
return true;
}
function handleItemClick(item: ContainerEntry) {
if (!currentServer.value) return;
// Les containers sont toujours navigables
if (item.is_container) {
handleContainerClick(item);
}
// Les tracks individuels : on ne fait rien (actions via boutons)
}
function toggleMenu(itemId: string, event: Event) {
event.stopPropagation();
openMenuId.value = openMenuId.value === itemId ? null : itemId;
}
function closeMenu() {
openMenuId.value = null;
}
async function handlePlayItem(event: Event, item: ContainerEntry) {
event.stopPropagation();
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() {
router.push("/debug");
close();
}
</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 server-drawer">
<!-- Header - Liste des serveurs -->
<header v-if="!isNavigating" class="drawer-header">
<div class="drawer-title-section">
<ServerIcon :size="24" />
<h2 class="drawer-title">Media Servers</h2>
</div>
<button
class="drawer-close-btn"
@click="close"
aria-label="Fermer"
>
<X :size="24" />
</button>
</header>
<!-- Header - Navigation dans un serveur -->
<header v-else class="drawer-header">
<button
class="drawer-back-btn"
@click="goBack"
aria-label="Retour aux serveurs"
>
<ChevronLeft :size="20" />
</button>
<div class="drawer-title-section">
<ServerIcon :size="20" />
<h2 class="drawer-title small">
{{ currentServer?.friendly_name }}
</h2>
</div>
<button
class="drawer-close-btn"
@click="close"
aria-label="Fermer"
>
<X :size="24" />
</button>
</header>
<!-- Breadcrumb -->
<nav
v-if="isNavigating && currentPath.length > 1"
class="breadcrumb"
>
<button
v-for="(crumb, index) in currentPath"
:key="crumb.id"
class="breadcrumb-item"
:class="{ active: index === currentPath.length - 1 }"
@click="handleBreadcrumbClick(index)"
>
{{ crumb.title }}
<ChevronRight
v-if="index < currentPath.length - 1"
:size="14"
/>
</button>
</nav>
<!-- Search bar (visible en mode navigation) -->
<div v-if="isNavigating" class="search-bar">
<input
v-model="searchInput"
type="text"
class="search-input"
placeholder="Rechercher..."
@keyup.enter="handleSearch"
/>
<button
v-if="searchInput"
class="search-clear-btn"
@click="handleClearSearch"
title="Effacer"
>
<X :size="14" />
</button>
<button class="search-btn" @click="handleSearch" title="Rechercher">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</button>
</div>
<!-- Contenu -->
<div ref="drawerContentRef" class="drawer-content">
<!-- Liste des serveurs -->
<div v-if="!isNavigating">
<!-- Servers online -->
<section
v-if="onlineServers.length > 0"
class="server-section"
>
<h3 class="section-title">
Disponibles ({{ onlineServers.length }})
</h3>
<ul class="server-list">
<li
v-for="server in onlineServers"
:key="server.id"
class="server-item online"
@click="handleServerClick(server)"
>
<div class="server-icon">
<ServerIcon :size="20" />
</div>
<div class="server-info">
<p class="server-name">
{{ server.friendly_name }}
</p>
<p
v-if="server.model_name"
class="server-model"
>
{{ server.model_name }}
</p>
</div>
<div class="server-status">
<Circle :size="8" fill="currentColor" />
</div>
</li>
</ul>
</section>
<!-- Servers offline -->
<section
v-if="offlineServers.length > 0"
class="server-section"
>
<h3 class="section-title">
Hors ligne ({{ offlineServers.length }})
</h3>
<ul class="server-list">
<li
v-for="server in offlineServers"
:key="server.id"
class="server-item offline"
>
<div class="server-icon">
<ServerIcon :size="20" />
</div>
<div class="server-info">
<p class="server-name">
{{ server.friendly_name }}
</p>
<p
v-if="server.model_name"
class="server-model"
>
{{ server.model_name }}
</p>
</div>
<div class="server-status">
<Circle :size="8" fill="currentColor" />
</div>
</li>
</ul>
</section>
<!-- Aucun server -->
<div
v-if="allServers.length === 0"
class="empty-servers"
>
<ServerIcon :size="48" />
<p>Aucun serveur multimédia détecté</p>
</div>
</div>
<!-- Navigation dans un serveur -->
<div v-else>
<!-- Loading -->
<div v-if="isLoading" class="loading-state">
<div class="spinner"></div>
<p>Chargement...</p>
</div>
<!-- Contenu du serveur -->
<ul v-else-if="browseData" class="content-list">
<li
v-for="item in browseData.entries"
:key="item.id"
class="content-item"
:class="{
navigable:
item.is_container && isNavigable(item),
'menu-open': openMenuId === item.id,
}"
@click="handleItemClick(item)"
>
<!-- Cover avec image ou icône -->
<div class="content-cover">
<img
v-if="
item.album_art_uri &&
!getImageState(item.id).error
"
v-show="getImageState(item.id).loaded"
:src="item.album_art_uri"
:alt="item.title"
class="cover-image"
@load="handleImageLoad(item.id)"
@error="handleImageError(item.id)"
/>
<div
v-if="
!item.album_art_uri ||
getImageState(item.id).error ||
!getImageState(item.id).loaded
"
class="cover-placeholder"
>
<Folder
v-if="item.is_container"
:size="24"
/>
<Music v-else :size="24" />
</div>
<!-- Petite icône de type pour containers jouables -->
<div
v-if="
item.is_container &&
isPlayable(item)
"
class="type-badge"
>
<Folder :size="12" />
</div>
</div>
<div class="content-info">
<p class="content-title">
{{ item.title }}
</p>
<p
v-if="item.artist"
class="content-subtitle artist"
>
{{ item.artist }}
</p>
<p
v-else-if="item.album"
class="content-subtitle"
>
{{ item.album }}
</p>
</div>
<!-- Actions pour items jouables -->
<div
v-if="isPlayable(item)"
class="content-actions"
>
<!-- Bouton Play -->
<button
class="action-btn play-btn"
:title="
item.is_container
? 'Jouer tout'
: 'Jouer'
"
@click="handlePlayItem($event, item)"
>
<Play :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) -->
<ChevronRight
v-if="
item.is_container && isNavigable(item)
"
:size="16"
class="content-chevron"
/>
</li>
</ul>
<!-- Sentinel infinite scroll -->
<div v-if="canLoadMore" ref="sentinelRef" class="scroll-sentinel" />
<!-- Spinner load more -->
<div v-if="loadingMore" class="load-more-spinner">
<div class="spinner"></div>
</div>
<!-- Vide -->
<div v-else-if="!browseData" class="empty-servers">
<Folder :size="48" />
<p>Dossier vide</p>
</div>
</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: 50vw; /* Commence après le drawer (desktop: 50vw) */
right: 0;
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) */
.server-drawer {
left: 0;
border-right: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 4px 0 32px rgba(0, 0, 0, 0.4);
}
@media (prefers-color-scheme: light) {
.server-drawer {
border-right-color: rgba(0, 0, 0, 0.1);
box-shadow: 4px 0 32px rgba(0, 0, 0, 0.15);
}
}
/* Breadcrumb */
.search-bar {
display: flex;
align-items: center;
gap: 6px;
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
}
.search-input {
flex: 1;
padding: 6px 10px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.07);
color: var(--color-text);
font-size: var(--text-sm);
min-width: 0;
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
}
.search-input::placeholder {
color: var(--color-text-tertiary);
}
.search-clear-btn,
.search-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
background: rgba(255, 255, 255, 0.07);
color: var(--color-text-secondary);
flex-shrink: 0;
}
.search-clear-btn:hover,
.search-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: var(--color-text);
}
.empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--color-text-tertiary);
font-size: var(--text-sm);
}
.breadcrumb {
display: flex;
align-items: center;
gap: 4px;
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
overflow-x: auto;
flex-shrink: 0;
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
font-size: var(--text-sm);
color: var(--color-text-secondary);
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.breadcrumb-item:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--color-text);
}
.breadcrumb-item.active {
color: var(--color-text);
font-weight: 600;
cursor: default;
}
.breadcrumb-item.active:hover {
background: transparent;
}
/* .drawer-content défini dans drawers.css */
.server-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);
}
.server-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.server-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;
}
.server-item.online {
cursor: pointer;
}
.server-item.online:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.2);
transform: translateX(4px);
}
.server-item.online:active {
transform: translateX(2px);
}
.server-item.offline {
opacity: 0.5;
cursor: not-allowed;
}
.server-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);
}
.server-info {
flex: 1;
min-width: 0;
}
.server-name {
font-size: var(--text-base);
font-weight: 600;
color: var(--color-text);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-model {
font-size: var(--text-sm);
color: var(--color-text-secondary);
margin: 2px 0 0 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-status {
flex-shrink: 0;
color: var(--status-playing);
}
.server-item.offline .server-status {
color: var(--status-offline);
}
/* Content list */
.content-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.content-item {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
transition: all 0.2s ease;
position: relative;
}
.content-item.navigable {
cursor: pointer;
}
.content-item.navigable:hover {
background: rgba(255, 255, 255, 0.12);
transform: translateX(2px);
}
.content-item.navigable:active {
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;
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
flex-shrink: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: var(--color-text-secondary);
overflow: hidden;
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
}
.type-badge {
position: absolute;
bottom: 3px;
right: 3px;
width: 18px;
height: 18px;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.9);
}
.content-info {
flex: 1;
min-width: 0;
}
.content-title {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.content-subtitle {
font-size: var(--text-xs);
color: var(--color-text-secondary);
margin: 2px 0 0 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.content-subtitle.artist {
font-weight: 500;
color: var(--color-text);
}
.content-chevron {
flex-shrink: 0;
color: var(--color-text-tertiary);
}
/* Actions pour items jouables */
.content-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
opacity: 1; /* Toujours visible pour le tactile */
transition: all 0.2s ease;
z-index: 10; /* Au-dessus pour capturer les clicks */
position: relative; /* Crée un contexte de stacking */
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
color: var(--color-text);
pointer-events: auto; /* S'assurer que les boutons capturent les clicks */
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.action-btn:active {
transform: scale(0.95);
}
.play-btn {
color: var(--color-primary);
border-color: var(--color-primary);
}
.play-btn:hover {
background: var(--color-primary);
color: white;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
}
.menu-btn {
color: var(--color-text-secondary);
}
.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;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-md);
padding: var(--spacing-2xl);
color: var(--color-text-secondary);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* @keyframes spin is now global in pmocontrol.css */
.loading-state p {
margin: 0;
font-size: var(--text-sm);
}
.empty-servers {
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-servers 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);
}
.scroll-sentinel {
height: 1px;
}
.load-more-spinner {
display: flex;
justify-content: center;
padding: var(--spacing-md);
color: var(--color-text-secondary);
}
/* 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;
/* Pas de delay au leave - disparaît en même temps que le drawer */
}
.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;
}
/* 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 {
width: 6px;
height: 6px;
}
.drawer-content::-webkit-scrollbar-track,
.breadcrumb::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
.drawer-content::-webkit-scrollbar-thumb,
.breadcrumb::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
.drawer-content::-webkit-scrollbar-thumb:hover,
.breadcrumb::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Mobile responsive - portrait */
@media (max-width: 768px) and (orientation: portrait) {
.server-drawer {
width: 100vw;
box-shadow: none;
}
.drawer-header {
padding: var(--spacing-md);
}
.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 */
@supports not (backdrop-filter: blur(30px)) {
.server-drawer {
background: rgba(255, 255, 255, 0.98);
}
@media (prefers-color-scheme: dark) {
.server-drawer {
background: rgba(20, 20, 30, 0.98);
}
}
}
</style>