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.
528 lines
13 KiB
Vue
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>
|