Files
pmomusic/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue
Eric Coissac 3979d346a5 ✏️ amélioration des messages d'erreur avec nom du lecteur
- Ajout de `rendererName` computed property dans plusieurs composants pour afficher le nom convivial du lecteur (ou l'ID en fallback)
- Mise à jour des messages d’erreur dans CurrentTrack, TransportControls et VolumeControl pour inclure le nom du lecteur (ex: « Sonos One » — impossible de…)
- Amélioration du message d’erreur dans useRenderers.ts : « Impossible de rafraîchir la liste des lecteurs audio » + message plus spécifique pour les snapshots
- Ajout de l’import `computed` manquant dans VolumeControl.vue
2026-04-09 18:03:00 +02:00

1139 lines
32 KiB
Vue

<script setup lang="ts">
import { computed, toRef, ref } from "vue";
import { useRenderer } from "@/composables/useRenderers";
import { useCoverImage } from "@/composables/useCoverImage";
import { useUIStore } from "@/stores/ui";
import { api } from "@/services/pmocontrol/api";
import { Music, X } from "lucide-vue-next";
const props = defineProps<{
rendererId: string;
}>();
const { state } = useRenderer(toRef(props, "rendererId"));
const uiStore = useUIStore();
const metadata = computed(() => state.value?.current_track);
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
const isSeeking = ref(false);
const isDragging = ref(false);
const dragProgress = ref(0);
const seekTargetMs = ref<number | null>(null);
// État pour l'overlay de la cover
const showCoverOverlay = ref(false);
const showMetadata = ref(false);
// Use the new cover image composable
const albumArtUri = computed(() => metadata.value?.album_art_uri);
const {
imageLoaded,
imageError,
coverImageRef,
cacheBustedUrl,
handleImageLoad,
handleImageError,
} = useCoverImage(albumArtUri);
function openCoverOverlay() {
if (hasCover.value) {
showCoverOverlay.value = true;
showMetadata.value = true; // Afficher les métadonnées par défaut
}
}
function closeCoverOverlay() {
showCoverOverlay.value = false;
showMetadata.value = false;
}
// Détecter le clic sur la partie basse de l'image
function handleOverlayContentClick(event: MouseEvent) {
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const clickY = event.clientY - rect.top;
const height = rect.height;
// Si le clic est dans le tiers inférieur, toggle les métadonnées
if (clickY > height * 0.66) {
showMetadata.value = !showMetadata.value;
}
}
// Calcul du pourcentage de progression
const progressPercent = computed(() => {
// Si on est en train de drag, utiliser la position du drag
if (isDragging.value) {
return dragProgress.value;
}
// Si on a une cible de seek active
if (seekTargetMs.value !== null) {
const currentPosition = state.value?.position_ms || 0;
// Si la position actuelle est proche de la cible (tolérance de 2 secondes)
if (Math.abs(currentPosition - seekTargetMs.value) < 2000) {
// On peut libérer le contrôle
seekTargetMs.value = null;
dragProgress.value = 0;
} else {
// Sinon, continuer à afficher la position cible
return dragProgress.value;
}
}
const position = state.value?.position_ms;
const duration = state.value?.duration_ms;
if (position && duration && duration > 0) {
// Plafonner à 100% pour éviter que le curseur dépasse en cas de délai de mise à jour
const percent = (position / duration) * 100;
return Math.min(percent, 100);
}
return 0;
});
// Formater la durée en MM:SS
function formatTime(ms: number | null | undefined): string {
if (!ms) return "--:--";
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
const currentTime = computed(() => formatTime(state.value?.position_ms));
const totalTime = computed(() => formatTime(state.value?.duration_ms));
const hasCover = computed(
() => !!metadata.value?.album_art_uri && !imageError.value,
);
// Calculer le pourcentage à partir d'une position X dans la barre
function calculateProgressFromX(
clientX: number,
progressBar: HTMLElement,
): number {
const rect = progressBar.getBoundingClientRect();
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
return (x / rect.width) * 100;
}
// Gestion du drag sur la progress bar
function handleProgressBarMouseDown(event: MouseEvent) {
const duration = state.value?.duration_ms;
if (!duration || duration === 0 || isSeeking.value) return;
const progressBar = event.currentTarget as HTMLElement;
isDragging.value = true;
dragProgress.value = calculateProgressFromX(event.clientX, progressBar);
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.value) return;
dragProgress.value = calculateProgressFromX(e.clientX, progressBar);
};
const handleMouseUp = async (e: MouseEvent) => {
if (!isDragging.value) return;
const finalPercent = calculateProgressFromX(e.clientX, progressBar);
const newPositionMs = (finalPercent / 100) * duration;
const newPositionSeconds = Math.floor(newPositionMs / 1000);
// Garder dragProgress à jour et définir la cible
dragProgress.value = finalPercent;
seekTargetMs.value = newPositionMs;
// D'abord arrêter le drag pour éviter les mouvements pendant le seek
isDragging.value = false;
try {
isSeeking.value = true;
await api.seekTo(props.rendererId, newPositionSeconds);
} catch (error) {
uiStore.notifyError(
`« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
// En cas d'erreur, réinitialiser
seekTargetMs.value = null;
dragProgress.value = 0;
} finally {
isSeeking.value = false;
}
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
// Gestion du seek sur la progress bar (clic simple)
async function handleProgressBarClick(event: MouseEvent) {
// Ne rien faire si on vient de drag (géré par mouseup)
if (isDragging.value) return;
const duration = state.value?.duration_ms;
if (!duration || duration === 0 || isSeeking.value) return;
const progressBar = event.currentTarget as HTMLElement;
const percent = calculateProgressFromX(event.clientX, progressBar);
const newPositionMs = (percent / 100) * duration;
const newPositionSeconds = Math.floor(newPositionMs / 1000);
// Définir la cible et la position visuelle
dragProgress.value = percent;
seekTargetMs.value = newPositionMs;
try {
isSeeking.value = true;
await api.seekTo(props.rendererId, newPositionSeconds);
} catch (error) {
uiStore.notifyError(
`« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
// En cas d'erreur, réinitialiser
seekTargetMs.value = null;
dragProgress.value = 0;
} finally {
isSeeking.value = false;
}
}
// Fonctions pour la progress bar de l'overlay (réutilisent la même logique)
function handleOverlayProgressBarClick(event: MouseEvent) {
handleProgressBarClick(event);
}
function handleOverlayProgressBarMouseDown(event: MouseEvent) {
handleProgressBarMouseDown(event);
}
// Gestion tactile pour la progress bar de l'overlay
function handleOverlayProgressBarTouchStart(event: TouchEvent) {
const duration = state.value?.duration_ms;
if (!duration || duration === 0 || isSeeking.value) return;
const progressBar = event.currentTarget as HTMLElement;
const touch = event.touches[0];
if (!touch) return;
isDragging.value = true;
dragProgress.value = calculateProgressFromX(touch.clientX, progressBar);
const handleTouchMove = (e: TouchEvent) => {
if (!isDragging.value) return;
const moveTouch = e.touches[0];
if (!moveTouch) return;
dragProgress.value = calculateProgressFromX(
moveTouch.clientX,
progressBar,
);
};
const handleTouchEnd = async (e: TouchEvent) => {
if (!isDragging.value) return;
const endTouch = e.changedTouches[0];
if (!endTouch) return;
const finalPercent = calculateProgressFromX(
endTouch.clientX,
progressBar,
);
const newPositionMs = (finalPercent / 100) * duration;
const newPositionSeconds = Math.floor(newPositionMs / 1000);
dragProgress.value = finalPercent;
seekTargetMs.value = newPositionMs;
isDragging.value = false;
try {
isSeeking.value = true;
await api.seekTo(props.rendererId, newPositionSeconds);
} catch (error) {
uiStore.notifyError(
`« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
seekTargetMs.value = null;
dragProgress.value = 0;
} finally {
isSeeking.value = false;
}
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("touchend", handleTouchEnd);
};
document.addEventListener("touchmove", handleTouchMove, { passive: true });
document.addEventListener("touchend", handleTouchEnd);
}
// Gestion du swipe down pour fermer l'overlay
const swipeStartY = ref(0);
const swipeCurrentY = ref(0);
const isSwiping = ref(false);
const swipeThreshold = 100; // Distance minimale pour fermer
function handleOverlayTouchStart(event: TouchEvent) {
const touch = event.touches[0];
if (!touch) return;
swipeStartY.value = touch.clientY;
swipeCurrentY.value = touch.clientY;
isSwiping.value = true;
// Ajouter les listeners sur le document pour capturer tous les mouvements
document.addEventListener("touchmove", handleSwipeTouchMove, {
passive: true,
});
document.addEventListener("touchend", handleSwipeTouchEnd);
document.addEventListener("touchcancel", handleSwipeTouchEnd);
}
function handleSwipeTouchMove(event: TouchEvent) {
if (!isSwiping.value) return;
const touch = event.touches[0];
if (!touch) return;
swipeCurrentY.value = touch.clientY;
}
function handleSwipeTouchEnd() {
if (!isSwiping.value) return;
const swipeDistance = swipeCurrentY.value - swipeStartY.value;
// Si swipe vers le bas suffisant, fermer l'overlay
if (swipeDistance > swipeThreshold) {
closeCoverOverlay();
}
isSwiping.value = false;
swipeStartY.value = 0;
swipeCurrentY.value = 0;
// Retirer les listeners
document.removeEventListener("touchmove", handleSwipeTouchMove);
document.removeEventListener("touchend", handleSwipeTouchEnd);
document.removeEventListener("touchcancel", handleSwipeTouchEnd);
}
// Ancienne fonction conservée pour compatibilité template (non utilisée)
function handleOverlayTouchMove(event: TouchEvent) {
handleSwipeTouchMove(event);
}
function handleOverlayTouchEnd() {
handleSwipeTouchEnd();
}
// Calcul du décalage visuel pendant le swipe
const swipeOffset = computed(() => {
if (!isSwiping.value) return 0;
const offset = swipeCurrentY.value - swipeStartY.value;
// Ne permettre que le swipe vers le bas (positif)
return Math.max(0, offset);
});
// Opacité qui diminue pendant le swipe
const swipeOpacity = computed(() => {
if (!isSwiping.value) return 1;
const offset = swipeOffset.value;
// Réduire l'opacité progressivement
return Math.max(0.3, 1 - offset / 300);
});
</script>
<template>
<div class="current-track">
<!-- Cover Art -->
<div
class="cover-container"
:class="{ clickable: hasCover }"
@click="openCoverOverlay"
>
<img
v-if="cacheBustedUrl"
ref="coverImageRef"
:style="{
opacity: imageLoaded && !imageError ? 1 : 0,
visibility: imageLoaded && !imageError ? 'visible' : 'hidden',
position: imageLoaded && !imageError ? 'relative' : 'absolute',
}"
:src="cacheBustedUrl"
:alt="metadata?.album || 'Album cover'"
class="cover-image"
@load="handleImageLoad"
@error="handleImageError"
/>
<div
v-show="!cacheBustedUrl || imageError || !imageLoaded"
class="cover-placeholder"
>
<Music :size="64" />
</div>
</div>
<!-- Metadata -->
<div class="metadata">
<h2 class="title">{{ metadata?.title || "Aucun titre" }}</h2>
<p class="artist">{{ metadata?.artist || "Artiste inconnu" }}</p>
<p class="album" v-if="metadata?.album">{{ metadata.album }}</p>
</div>
<!-- Progress Bar -->
<div class="progress-section">
<div
class="progress-bar"
@click="handleProgressBarClick"
@mousedown="handleProgressBarMouseDown"
@touchstart="handleOverlayProgressBarTouchStart"
:class="{ seeking: isSeeking, dragging: isDragging }"
>
<div
class="progress-bar-fill"
:style="{ width: `${progressPercent}%` }"
>
<div class="progress-bar-thumb"></div>
</div>
</div>
<div class="time-display">
<span>{{ currentTime }}</span>
<span>{{ totalTime }}</span>
</div>
</div>
<!-- Overlay cover en grand avec effet glassmorphism -->
<Teleport to="body">
<Transition name="cover-overlay">
<div
v-if="showCoverOverlay"
class="cover-overlay"
@click="closeCoverOverlay"
>
<div
class="cover-overlay-content"
:class="{ swiping: isSwiping }"
:style="{
transform: `translateY(${swipeOffset}px)`,
opacity: swipeOpacity,
}"
@click.stop="handleOverlayContentClick"
@touchstart="handleOverlayTouchStart"
@touchmove="handleOverlayTouchMove"
@touchend="handleOverlayTouchEnd"
>
<button
class="cover-overlay-close"
@click="closeCoverOverlay"
title="Fermer"
>
<X :size="24" />
</button>
<img
v-if="hasCover && cacheBustedUrl"
:src="cacheBustedUrl"
:alt="metadata?.album || 'Album cover'"
class="cover-overlay-image"
/>
<Transition name="metadata-fade">
<div
v-if="metadata && showMetadata"
class="cover-overlay-metadata"
>
<div class="overlay-metadata-text">
<h2>{{ metadata.title }}</h2>
<p class="artist">{{ metadata.artist }}</p>
<p v-if="metadata.album" class="album">
{{ metadata.album }}
</p>
</div>
<div class="overlay-progress-section">
<div
class="overlay-progress-bar"
@click.stop="
handleOverlayProgressBarClick
"
@mousedown.stop="
handleOverlayProgressBarMouseDown
"
@touchstart.stop="
handleOverlayProgressBarTouchStart
"
:class="{
seeking: isSeeking,
dragging: isDragging,
}"
>
<div
class="overlay-progress-bar-fill"
:style="{
width: `${progressPercent}%`,
}"
>
<div
class="overlay-progress-bar-thumb"
></div>
</div>
</div>
<div class="overlay-time-display">
<span>{{ currentTime }}</span>
<span>{{ totalTime }}</span>
</div>
</div>
</div>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
.current-track {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.cover-container {
width: 100%;
aspect-ratio: 1;
max-width: 300px;
margin: 0 auto;
border-radius: var(--radius-lg);
overflow: hidden;
background-color: var(--color-bg-secondary);
box-shadow: var(--shadow-lg);
transition:
transform 0.3s ease,
box-shadow 0.3s ease;
}
.cover-container.clickable {
cursor: pointer;
}
.cover-container.clickable:hover {
transform: scale(1.02);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-tertiary);
}
.metadata {
text-align: center;
}
.title {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin: 0 0 var(--spacing-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artist {
font-size: var(--text-lg);
color: var(--color-text-secondary);
margin: 0 0 var(--spacing-xs);
}
.album {
font-size: var(--text-base);
color: var(--color-text-tertiary);
margin: 0;
}
.progress-section {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding: 0 10px;
}
.progress-bar {
position: relative;
width: 100%;
height: 6px;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.15) 0%,
rgba(255, 255, 255, 0.25) 50%,
rgba(255, 255, 255, 0.15) 100%
);
border-radius: 3px;
cursor: pointer;
transition: background 0.2s ease;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
overflow: visible;
}
.progress-bar:hover {
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0.35) 50%,
rgba(255, 255, 255, 0.2) 100%
);
}
.progress-bar.dragging {
cursor: grabbing;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0.35) 50%,
rgba(255, 255, 255, 0.2) 100%
);
}
.progress-bar.seeking {
cursor: wait;
opacity: 0.7;
}
.progress-bar-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg, #059669 0%, #10b981 50%, #34d399 100%);
border-radius: 3px;
transition: width 0.1s linear;
z-index: 1;
box-shadow: 0 0 8px rgba(16, 185, 129, 0.5);
overflow: visible;
}
.progress-bar.dragging .progress-bar-fill {
transition: width 0s;
box-shadow: 0 0 12px rgba(16, 185, 129, 0.8);
}
.progress-bar-thumb {
position: absolute;
right: -10px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
background: white;
border: 3px solid rgba(0, 0, 0, 0.8);
border-radius: 50%;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.6),
inset 0 1px 2px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
z-index: 2;
cursor: grab;
}
.progress-bar:hover .progress-bar-thumb {
transform: translateY(-50%) scale(1.2);
box-shadow:
0 3px 12px rgba(0, 0, 0, 0.7),
inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.progress-bar.dragging .progress-bar-thumb {
transform: translateY(-50%) scale(1.3);
cursor: grabbing;
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.8),
inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.time-display {
display: flex;
justify-content: space-between;
font-size: var(--text-sm);
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
/* Responsive */
@media (min-width: 768px) {
.cover-container {
max-width: 250px;
}
}
@media (min-width: 1024px) {
.cover-container {
max-width: 300px;
}
}
/* Mode kiosque - compactage pour petites hauteurs (800x600) */
@media (max-height: 700px) and (orientation: landscape) {
.current-track {
gap: var(--spacing-sm);
}
.cover-container {
max-width: 160px !important;
max-height: 160px;
}
.cover-placeholder {
font-size: 40px;
}
.metadata {
margin-top: -8px;
}
.title {
font-size: var(--text-lg);
margin: 0 0 4px;
}
.artist {
font-size: var(--text-sm);
margin: 0 0 2px;
}
.album {
font-size: var(--text-xs);
}
.progress-section {
gap: 4px;
}
.time-display {
font-size: 11px;
}
}
/* Overlay cover en grand - Liquid metal style */
.cover-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
padding: 16px;
/* Empêcher le pull-to-refresh Android de se déclencher */
overscroll-behavior: contain;
touch-action: none;
}
.cover-overlay-content {
position: relative;
width: calc(100vw - 32px);
height: calc(100dvh - 32px);
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 24px;
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 rgba(0, 0, 0, 0.3);
}
@media (prefers-color-scheme: dark) {
.cover-overlay-content {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.15);
}
.cover-overlay-metadata {
background: rgba(0, 0, 0, 0.4);
}
}
.cover-overlay-close {
position: absolute;
top: var(--spacing-md);
right: var(--spacing-md);
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
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;
color: var(--color-text);
transition: all 0.3s ease;
z-index: 10;
}
.cover-overlay-close:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1) rotate(90deg);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.cover-overlay-close:active {
transform: scale(1) rotate(90deg);
}
@media (prefers-color-scheme: dark) {
.cover-overlay-close {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.2);
}
.cover-overlay-close:hover {
background: rgba(0, 0, 0, 0.5);
}
}
.cover-overlay-image {
width: calc(100vw - 64px);
height: calc(100dvh - 64px);
border-radius: 16px;
box-shadow:
0 30px 80px rgba(0, 0, 0, 0.6),
0 0 2px rgba(255, 255, 255, 0.2);
object-fit: contain;
}
.cover-overlay-metadata {
position: absolute;
bottom: 24px;
left: 15%;
right: 15%;
text-align: center;
padding: var(--spacing-lg);
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(30px) saturate(180%);
-webkit-backdrop-filter: blur(30px) saturate(180%);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
}
.cover-overlay-metadata h2 {
font-size: clamp(1.5rem, 4vw, 2.5rem);
font-weight: 700;
color: white;
margin: 0;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.8);
line-height: 1.2;
}
.cover-overlay-metadata .artist {
font-size: clamp(1rem, 2.5vw, 1.5rem);
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin: 8px 0 0 0;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.6);
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.cover-overlay-metadata .album {
font-size: clamp(0.875rem, 2vw, 1.125rem);
font-weight: 400;
font-style: italic;
color: rgba(255, 255, 255, 0.75);
margin: 6px 0 0 0;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
}
/* Animations de transition */
.cover-overlay-enter-active,
.cover-overlay-leave-active {
transition: all 0.3s ease;
}
.cover-overlay-enter-active .cover-overlay-content,
.cover-overlay-leave-active .cover-overlay-content {
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.cover-overlay-enter-from,
.cover-overlay-leave-to {
opacity: 0;
backdrop-filter: blur(0px);
-webkit-backdrop-filter: blur(0px);
}
.cover-overlay-enter-from .cover-overlay-content,
.cover-overlay-leave-to .cover-overlay-content {
opacity: 0;
transform: scale(0.9);
}
.cover-overlay-enter-to,
.cover-overlay-leave-from {
opacity: 1;
}
.cover-overlay-enter-to .cover-overlay-content,
.cover-overlay-leave-from .cover-overlay-content {
opacity: 1;
transform: scale(1);
}
/* Responsive overlay */
@media (max-width: 768px) {
.cover-overlay {
padding: 12px;
}
.cover-overlay-content {
width: calc(100vw - 24px);
height: calc(100dvh - 24px);
border-radius: 16px;
}
.cover-overlay-image {
width: calc(100vw - 48px);
height: calc(100dvh - 48px);
}
.cover-overlay-metadata {
bottom: 16px;
left: 16px;
right: 16px;
padding: var(--spacing-md);
}
.cover-overlay-close {
width: 40px;
height: 40px;
top: 12px;
right: 12px;
}
}
/* Mode kiosque - overlay adapté */
@media (max-height: 700px) and (orientation: landscape) {
.cover-overlay {
padding: 12px;
}
.cover-overlay-content {
width: calc(100vw - 24px);
height: calc(100dvh - 24px);
}
.cover-overlay-image {
width: calc(100vw - 48px);
height: calc(100dvh - 48px);
}
.cover-overlay-metadata {
bottom: 12px;
left: 12px;
right: 12px;
padding: var(--spacing-sm);
}
.cover-overlay-metadata .artist {
padding-top: 4px;
}
.cover-overlay-metadata .album {
display: none; /* Masquer l'album en mode kiosque pour plus d'espace */
}
}
/* Transition pour les métadonnées */
.metadata-fade-enter-active,
.metadata-fade-leave-active {
transition: all 0.3s ease;
}
.metadata-fade-enter-from,
.metadata-fade-leave-to {
opacity: 0;
transform: translateY(20px);
}
.metadata-fade-enter-to,
.metadata-fade-leave-from {
opacity: 1;
transform: translateY(0);
}
/* Swipe down pour fermer */
.cover-overlay-content {
transition:
transform 0.1s ease-out,
opacity 0.1s ease-out;
}
.cover-overlay-content:not(.swiping) {
transition:
transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
opacity 0.3s ease;
}
/* Progress bar dans l'overlay - style glassmorphism macOS */
.overlay-metadata-text {
margin-bottom: var(--spacing-md);
}
.overlay-progress-section {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding-top: var(--spacing-md);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.overlay-progress-bar {
position: relative;
width: 100%;
height: 8px;
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
cursor: pointer;
transition: background 0.2s ease;
box-shadow:
inset 0 1px 3px rgba(0, 0, 0, 0.4),
0 1px 0 rgba(255, 255, 255, 0.1);
overflow: visible;
}
.overlay-progress-bar:hover {
background: rgba(255, 255, 255, 0.2);
}
.overlay-progress-bar.dragging {
cursor: grabbing;
background: rgba(255, 255, 255, 0.25);
}
.overlay-progress-bar.seeking {
cursor: wait;
opacity: 0.7;
}
.overlay-progress-bar-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg, #059669 0%, #10b981 50%, #34d399 100%);
border-radius: 4px;
transition: width 0.1s linear;
z-index: 1;
box-shadow: 0 0 12px rgba(16, 185, 129, 0.6);
overflow: visible;
}
.overlay-progress-bar.dragging .overlay-progress-bar-fill {
transition: width 0s;
box-shadow: 0 0 16px rgba(16, 185, 129, 0.9);
}
.overlay-progress-bar-thumb {
position: absolute;
right: -12px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
background: rgba(255, 255, 255, 0.95);
border: 2px solid rgba(16, 185, 129, 0.8);
border-radius: 50%;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.5),
0 0 12px rgba(16, 185, 129, 0.4),
inset 0 1px 2px rgba(255, 255, 255, 0.5);
transition: all 0.2s ease;
z-index: 2;
cursor: grab;
}
.overlay-progress-bar:hover .overlay-progress-bar-thumb {
transform: translateY(-50%) scale(1.15);
box-shadow:
0 3px 12px rgba(0, 0, 0, 0.6),
0 0 16px rgba(16, 185, 129, 0.5),
inset 0 1px 2px rgba(255, 255, 255, 0.5);
}
.overlay-progress-bar.dragging .overlay-progress-bar-thumb {
transform: translateY(-50%) scale(1.25);
cursor: grabbing;
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.7),
0 0 20px rgba(16, 185, 129, 0.6),
inset 0 1px 2px rgba(255, 255, 255, 0.5);
}
.overlay-time-display {
display: flex;
justify-content: space-between;
font-size: var(--text-sm);
color: rgba(255, 255, 255, 0.8);
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
/* Responsive progress bar overlay */
@media (max-width: 768px) {
.overlay-progress-bar {
height: 10px;
}
.overlay-progress-bar-thumb {
width: 28px;
height: 28px;
right: -14px;
}
.overlay-time-display {
font-size: var(--text-base);
}
}
/* Mode kiosque - progress bar overlay */
@media (max-height: 700px) and (orientation: landscape) {
.overlay-progress-section {
padding-top: var(--spacing-xs);
}
.overlay-progress-bar {
height: 6px;
}
.overlay-progress-bar-thumb {
width: 20px;
height: 20px;
right: -10px;
}
.overlay-time-display {
font-size: 11px;
}
}
</style>