Files
pmomusic/pmoapp/webapp/src/components/pmocontrol/TimerControl.vue
Eric Coissac 7f10f55086 Implémentation du sleep timer
Ajout de la fonctionnalité de sleep timer pour les renderers audio.

- Création du composant TimerControl.vue avec interface utilisateur interactive
- Implémentation des API endpoints pour gérer le sleep timer (start, update, cancel, get)
- Intégration du système de timer dans le control point avec surveillance par thread dédié
- Ajout de la logique de timer dans les renderers (sleep_timer.rs)
- Mise à jour des types et événements SSE pour le sleep timer
- Intégration du timer dans la barre de bas de l'application

Cette fonctionnalité permet aux utilisateurs de configurer un timer qui arrêtera automatiquement la lecture après une durée définie, avec une interface intuitive et des notifications en temps réel.
2026-01-10 20:45:51 +01:00

528 lines
13 KiB
Vue

<template>
<div class="timer-control">
<button
class="timer-button"
:class="{ active: timerState?.active }"
@click.stop="toggleTimerDialog"
:title="buttonTitle"
ref="buttonRef"
>
<Clock :size="24" />
<span
v-if="timerState?.active && remainingMinutes !== null"
class="timer-badge"
>
{{ remainingMinutes }}
</span>
</button>
<!-- Backdrop et Dialog (téléportés au body) -->
<Teleport to="body">
<div
v-if="showDialog"
class="timer-backdrop"
@click="closeDialog"
></div>
<div v-if="showDialog" class="timer-dialog" @click.stop>
<div class="timer-dialog-content">
<div class="timer-header">
<h3>Sleep Timer</h3>
<button class="close-button" @click="closeDialog">
<X :size="18" />
</button>
</div>
<div class="timer-body">
<!-- Affichage compact du temps restant -->
<div v-if="timerState?.active" class="time-display">
{{ formatTime(remainingSeconds) }}
</div>
<!-- Slider compact -->
<div class="slider-section">
<div class="slider-value">
{{ sliderValue }} min
</div>
<input
type="range"
min="0"
max="120"
step="5"
v-model.number="sliderValue"
class="timer-slider"
@change="handleSliderChange"
/>
<div class="slider-marks">
<span>0</span>
<span>60</span>
<span>120</span>
</div>
</div>
<!-- Bouton annuler (seulement si actif) -->
<button
v-if="timerState?.active"
class="btn-cancel"
@click="handleCancel"
:disabled="isLoading"
>
Annuler le timer
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { Clock, X } from "lucide-vue-next";
import { api } from "@/services/pmocontrol/api";
import { sse } from "@/services/pmocontrol/sse";
import type {
SleepTimerState,
RendererEventPayload,
} from "@/services/pmocontrol/types";
const props = defineProps<{
rendererId: string;
}>();
const showDialog = ref(false);
const sliderValue = ref(0);
const timerState = ref<SleepTimerState | null>(null);
const isLoading = ref(false);
const buttonRef = ref<HTMLElement | null>(null);
const localRemainingSeconds = ref<number | null>(null);
let countdownInterval: number | null = null;
const remainingSeconds = computed(
() =>
localRemainingSeconds.value ??
timerState.value?.remaining_seconds ??
null,
);
const remainingMinutes = computed(() => {
if (remainingSeconds.value === null) return null;
return Math.ceil(remainingSeconds.value / 60);
});
const buttonTitle = computed(() => {
if (timerState.value?.active && remainingMinutes.value !== null) {
return `Sleep timer: ${remainingMinutes.value} min restantes`;
}
return "Sleep timer";
});
function formatTime(seconds: number | null): string {
if (seconds === null) return "--:--";
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function startCountdown() {
if (countdownInterval !== null) {
clearInterval(countdownInterval);
}
if (timerState.value?.active && timerState.value.remaining_seconds) {
localRemainingSeconds.value = timerState.value.remaining_seconds;
countdownInterval = window.setInterval(() => {
if (
localRemainingSeconds.value !== null &&
localRemainingSeconds.value > 0
) {
localRemainingSeconds.value--;
} else {
stopCountdown();
}
}, 1000);
}
}
function stopCountdown() {
if (countdownInterval !== null) {
clearInterval(countdownInterval);
countdownInterval = null;
}
localRemainingSeconds.value = null;
}
async function fetchTimerState() {
try {
const state = await api.getSleepTimer(props.rendererId);
timerState.value = state;
if (state.active && state.duration_seconds) {
sliderValue.value = Math.round(state.duration_seconds / 60);
startCountdown();
} else {
stopCountdown();
}
} catch (error) {
console.error("Erreur lors de la récupération du timer:", error);
timerState.value = null;
stopCountdown();
}
}
// Le slider modifie le timer en temps réel
async function handleSliderChange() {
if (sliderValue.value === 0) {
// Si on met à 0, on annule
await handleCancel();
return;
}
isLoading.value = true;
try {
const durationSeconds = sliderValue.value * 60;
if (timerState.value?.active) {
await api.updateSleepTimer(props.rendererId, durationSeconds);
} else {
await api.startSleepTimer(props.rendererId, durationSeconds);
}
await fetchTimerState();
} catch (error) {
console.error("Erreur lors de la configuration du timer:", error);
} finally {
isLoading.value = false;
}
}
async function handleCancel() {
isLoading.value = true;
try {
await api.cancelSleepTimer(props.rendererId);
timerState.value = {
active: false,
duration_seconds: 0,
remaining_seconds: null,
};
sliderValue.value = 0;
closeDialog();
} catch (error) {
console.error("Erreur lors de l'annulation du timer:", error);
} finally {
isLoading.value = false;
}
}
function toggleTimerDialog() {
showDialog.value = !showDialog.value;
}
function closeDialog() {
showDialog.value = false;
}
let sseUnsubscribe: (() => void) | null = null;
function handleTimerEvent(event: RendererEventPayload) {
if (event.renderer_id !== props.rendererId) return;
switch (event.type) {
case "timer_started":
case "timer_updated":
timerState.value = {
active: true,
duration_seconds: event.duration_seconds,
remaining_seconds: event.remaining_seconds,
};
startCountdown();
break;
case "timer_tick":
if (timerState.value?.active) {
timerState.value = {
...timerState.value,
remaining_seconds: event.remaining_seconds,
};
// Resynchroniser le countdown local
localRemainingSeconds.value = event.remaining_seconds;
}
break;
case "timer_expired":
case "timer_cancelled":
timerState.value = {
active: false,
duration_seconds: 0,
remaining_seconds: null,
};
sliderValue.value = 0;
stopCountdown();
break;
}
}
// Surveiller les changements de renderer
watch(
() => props.rendererId,
() => {
// Réinitialiser l'état quand on change de renderer
stopCountdown();
showDialog.value = false;
sliderValue.value = 0;
timerState.value = null;
// Charger l'état du nouveau renderer
fetchTimerState();
},
);
onMounted(() => {
fetchTimerState();
sseUnsubscribe = sse.onRendererEvent(handleTimerEvent);
});
onUnmounted(() => {
if (sseUnsubscribe) {
sseUnsubscribe();
}
stopCountdown();
});
</script>
<style scoped>
.timer-control {
position: relative;
}
.timer-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);
}
.timer-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.timer-button:active {
transform: scale(0.95);
}
.timer-button.active {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
border-color: rgba(34, 197, 94, 0.4);
}
@media (prefers-color-scheme: dark) {
.timer-button {
background: rgba(255, 255, 255, 0.15);
}
.timer-button:hover {
background: rgba(255, 255, 255, 0.25);
}
}
.timer-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;
background: rgba(34, 197, 94, 0.9);
border: 2px solid var(--color-bg);
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.timer-backdrop {
position: fixed;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
margin: 0;
padding: 0;
}
.timer-dialog {
position: fixed;
bottom: 60px;
right: 20px;
z-index: 1000;
}
.timer-dialog-content {
background: var(--background-secondary, #1f2937);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
width: 280px;
}
.timer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
}
.timer-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #ffffff);
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-secondary, #9ca3af);
cursor: pointer;
transition: all 0.2s ease;
}
.close-button:hover {
background: var(--glass-background, rgba(255, 255, 255, 0.1));
color: var(--text-primary, #ffffff);
}
.timer-body {
padding: 1rem;
}
.time-display {
text-align: center;
font-size: 28px;
font-weight: 600;
color: var(--status-playing, #22c55e);
margin-bottom: 0.75rem;
font-variant-numeric: tabular-nums;
}
.slider-section {
margin-bottom: 0.75rem;
}
.slider-value {
text-align: center;
font-size: 14px;
font-weight: 500;
color: var(--text-primary, #ffffff);
margin-bottom: 0.5rem;
}
.timer-slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--glass-background, rgba(255, 255, 255, 0.1));
outline: none;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.timer-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--status-playing, #22c55e);
cursor: pointer;
transition: all 0.2s ease;
}
.timer-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.timer-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--status-playing, #22c55e);
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.timer-slider::-moz-range-thumb:hover {
transform: scale(1.2);
}
.slider-marks {
display: flex;
justify-content: space-between;
margin-top: 0.25rem;
font-size: 10px;
color: var(--text-secondary, #9ca3af);
}
.btn-cancel {
width: 100%;
padding: 0.5rem;
border-radius: 6px;
border: none;
font-size: 13px;
font-weight: 500;
background: var(--glass-background, rgba(255, 255, 255, 0.1));
color: var(--text-primary, #ffffff);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-cancel:hover:not(:disabled) {
background: var(--glass-background-hover, rgba(255, 255, 255, 0.15));
}
.btn-cancel:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 768px) {
.timer-dialog-content {
width: 260px;
}
.timer-button {
width: 36px;
height: 36px;
}
}
</style>