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.
This commit is contained in:
527
pmoapp/webapp/src/components/pmocontrol/TimerControl.vue
Normal file
527
pmoapp/webapp/src/components/pmocontrol/TimerControl.vue
Normal file
@@ -0,0 +1,527 @@
|
||||
<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>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from "vue";
|
||||
import { Server, Music2 } from "lucide-vue-next";
|
||||
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||
import TimerControl from "@/components/pmocontrol/TimerControl.vue";
|
||||
import type {
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
@@ -112,6 +113,11 @@ function handleRendererDrawerClick() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sleep Timer (si un renderer est actif) -->
|
||||
<div v-if="activeRenderer" class="timer-section">
|
||||
<TimerControl :renderer-id="activeRenderer.id" />
|
||||
</div>
|
||||
|
||||
<!-- Bouton pour ouvrir le drawer des renderers (droite) -->
|
||||
<button
|
||||
class="drawer-button renderer-drawer-button"
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
VolumeSetRequest,
|
||||
AttachPlaylistRequest,
|
||||
PlayContentRequest,
|
||||
SleepTimerState,
|
||||
SuccessResponse,
|
||||
ErrorResponse,
|
||||
} from "./types";
|
||||
@@ -409,6 +410,67 @@ class PMOControlAPI {
|
||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SLEEP TIMER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Récupère l'état du sleep timer
|
||||
* GET /api/control/renderers/{rendererId}/timer
|
||||
*/
|
||||
async getSleepTimer(rendererId: string): Promise<SleepTimerState> {
|
||||
return this.request<SleepTimerState>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/timer`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre le sleep timer
|
||||
* POST /api/control/renderers/{rendererId}/timer/start
|
||||
*/
|
||||
async startSleepTimer(
|
||||
rendererId: string,
|
||||
durationSeconds: number,
|
||||
): Promise<SleepTimerState> {
|
||||
return this.request<SleepTimerState>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/timer/start`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ duration_seconds: durationSeconds }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le sleep timer (modifie la durée)
|
||||
* POST /api/control/renderers/{rendererId}/timer/update
|
||||
*/
|
||||
async updateSleepTimer(
|
||||
rendererId: string,
|
||||
durationSeconds: number,
|
||||
): Promise<SleepTimerState> {
|
||||
return this.request<SleepTimerState>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/timer/update`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ duration_seconds: durationSeconds }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule le sleep timer
|
||||
* POST /api/control/renderers/{rendererId}/timer/cancel
|
||||
*/
|
||||
async cancelSleepTimer(rendererId: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/timer/cancel`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
|
||||
@@ -200,6 +200,36 @@ export type RendererEventPayload =
|
||||
container_id: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "timer_started";
|
||||
renderer_id: string;
|
||||
duration_seconds: number;
|
||||
remaining_seconds: number;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "timer_updated";
|
||||
renderer_id: string;
|
||||
duration_seconds: number;
|
||||
remaining_seconds: number;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "timer_tick";
|
||||
renderer_id: string;
|
||||
remaining_seconds: number;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "timer_expired";
|
||||
renderer_id: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "timer_cancelled";
|
||||
renderer_id: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "online";
|
||||
renderer_id: string;
|
||||
@@ -254,3 +284,17 @@ export interface PositionInfo {
|
||||
rel_time: string | null; // Format HH:MM:SS
|
||||
track_duration: string | null; // Format HH:MM:SS
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SLEEP TIMER
|
||||
// ============================================================================
|
||||
|
||||
export interface SleepTimerState {
|
||||
active: boolean;
|
||||
duration_seconds: number;
|
||||
remaining_seconds: number | null;
|
||||
}
|
||||
|
||||
export interface SleepTimerRequest {
|
||||
duration_seconds: number; // 0-7200 (0-2 heures)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user