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:
2026-01-10 18:14:46 +01:00
parent 220eeb1244
commit 7f10f55086
12 changed files with 1365 additions and 1 deletions

View 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>

View File

@@ -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"

View File

@@ -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

View File

@@ -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)
}

View File

@@ -494,6 +494,92 @@ impl ControlPoint {
}
})?;
// Thread de surveillance des sleep timers
// Vérifie toutes les secondes les timers actifs et émet des événements
let registry_for_timer = Arc::clone(&registry);
let event_bus_for_timer = event_bus.clone();
thread::spawn(move || {
use std::collections::HashMap;
// Track last emitted tick for each renderer to avoid spamming events
let mut last_tick: HashMap<DeviceId, u32> = HashMap::new();
loop {
thread::sleep(Duration::from_secs(1));
// Get all renderers with active timers
let renderers = {
let reg = registry_for_timer.read().unwrap();
match reg.list_renderers() {
Ok(renderers) => renderers,
Err(err) => {
warn!(error = %err, "Failed to list renderers in timer watchdog");
continue;
}
}
};
for renderer in &renderers {
// Skip renderers without active timers
if !renderer.is_sleep_timer_active() {
continue;
}
let renderer_id = renderer.id();
let (is_active, duration, remaining) = renderer.sleep_timer_state();
if !is_active {
continue;
}
let remaining_seconds = remaining.unwrap_or(0);
// Check if timer has expired
if renderer.is_sleep_timer_expired() {
debug!(
renderer = renderer_id.0.as_str(),
"Sleep timer expired, stopping playback"
);
// Stop playback
if let Err(err) = renderer.stop() {
warn!(
renderer = renderer_id.0.as_str(),
error = %err,
"Failed to stop renderer when timer expired"
);
}
// Cancel the timer
renderer.cancel_sleep_timer();
// Emit TimerExpired event
event_bus_for_timer.broadcast(RendererEvent::TimerExpired {
id: renderer_id.clone(),
});
// Remove from tick tracking
last_tick.remove(&renderer_id);
} else {
// Emit tick event every second
let should_emit_tick = last_tick
.get(&renderer_id)
.map(|&last| remaining_seconds != last)
.unwrap_or(true);
if should_emit_tick {
event_bus_for_timer.broadcast(RendererEvent::TimerTick {
id: renderer_id.clone(),
remaining_seconds,
});
last_tick.insert(renderer_id, remaining_seconds);
}
}
}
}
});
Ok(Self {
registry,
// udn_cache,

View File

@@ -434,6 +434,26 @@ pub enum RendererEvent {
id: DeviceId,
binding: Option<PlaylistBinding>,
},
TimerStarted {
id: DeviceId,
duration_seconds: u32,
remaining_seconds: u32,
},
TimerUpdated {
id: DeviceId,
duration_seconds: u32,
remaining_seconds: u32,
},
TimerTick {
id: DeviceId,
remaining_seconds: u32,
},
TimerExpired {
id: DeviceId,
},
TimerCancelled {
id: DeviceId,
},
Online {
id: DeviceId,
info: DeviceBasicInfo,

View File

@@ -10,6 +10,7 @@ mod capabilities;
mod chromecast_renderer;
mod musicrenderer;
mod sleep_timer;
pub mod time_utils;
use std::sync::{Arc, Mutex};
@@ -18,6 +19,7 @@ pub use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
};
pub use crate::music_renderer::musicrenderer::{MusicRenderer, PlaylistBinding};
pub use crate::music_renderer::sleep_timer::SleepTimer;
use crate::{
RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend,
};

View File

@@ -19,6 +19,7 @@ use crate::music_renderer::capabilities::{
use crate::music_renderer::chromecast_renderer::ChromecastRenderer;
use crate::music_renderer::linkplay_renderer::LinkPlayRenderer;
use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
use crate::music_renderer::sleep_timer::SleepTimer;
use crate::music_renderer::upnp_renderer::UpnpRenderer;
use crate::online::DeviceConnectionState;
use crate::queue::{
@@ -77,6 +78,8 @@ struct MusicRendererState {
playback_source: PlaybackSource,
/// Flag to distinguish user-requested stop from automatic events.
user_stop_requested: bool,
/// Sleep timer for auto-stop functionality.
sleep_timer: SleepTimer,
}
#[derive(Debug, Clone)]
@@ -695,6 +698,76 @@ impl MusicRenderer {
state.user_stop_requested = false;
was_requested
}
// --- Sleep Timer Management ---
/// Starts the sleep timer with the given duration in seconds.
/// Maximum duration is 2 hours (7200 seconds).
///
/// Returns the remaining seconds after starting.
///
/// # Errors
/// Returns an error if the duration is invalid (0 or > 7200 seconds).
pub fn start_sleep_timer(&self, duration_seconds: u32) -> Result<u32, ControlPointError> {
let mut state = self.state.lock().unwrap();
state
.sleep_timer
.start(duration_seconds)
.map_err(|e| ControlPointError::ControlPoint(e))?;
Ok(state.sleep_timer.remaining_seconds().unwrap_or(0))
}
/// Updates the sleep timer duration. Resets the timer to the new duration from now.
///
/// Returns the new remaining seconds.
///
/// # Errors
/// Returns an error if the duration is invalid (0 or > 7200 seconds).
pub fn update_sleep_timer(&self, duration_seconds: u32) -> Result<u32, ControlPointError> {
let mut state = self.state.lock().unwrap();
state
.sleep_timer
.update(duration_seconds)
.map_err(|e| ControlPointError::ControlPoint(e))?;
Ok(state.sleep_timer.remaining_seconds().unwrap_or(0))
}
/// Cancels the sleep timer.
pub fn cancel_sleep_timer(&self) {
self.state.lock().unwrap().sleep_timer.cancel();
}
/// Returns the remaining seconds of the sleep timer, or None if no timer is active.
pub fn sleep_timer_remaining(&self) -> Option<u32> {
self.state.lock().unwrap().sleep_timer.remaining_seconds()
}
/// Returns the configured duration of the sleep timer in seconds.
pub fn sleep_timer_duration(&self) -> u32 {
self.state.lock().unwrap().sleep_timer.duration_seconds()
}
/// Returns true if the sleep timer is active.
pub fn is_sleep_timer_active(&self) -> bool {
self.state.lock().unwrap().sleep_timer.is_active()
}
/// Returns true if the sleep timer has expired.
pub fn is_sleep_timer_expired(&self) -> bool {
self.state.lock().unwrap().sleep_timer.is_expired()
}
/// Gets the sleep timer state as a tuple (is_active, duration_seconds, remaining_seconds).
pub fn sleep_timer_state(&self) -> (bool, u32, Option<u32>) {
let state = self.state.lock().unwrap();
(
state.sleep_timer.is_active(),
state.sleep_timer.duration_seconds(),
state.sleep_timer.remaining_seconds(),
)
}
}
/// Helper function to build DIDL-Lite metadata XML from TrackMetadata

View File

@@ -0,0 +1,199 @@
//! Sleep timer functionality for auto-stop feature.
//!
//! Provides a sleep timer that can automatically stop playback after a configured duration.
//! Maximum duration is 2 hours (7200 seconds).
use std::time::Instant;
/// Sleep timer state for auto-stop functionality.
#[derive(Debug, Clone)]
pub struct SleepTimer {
/// When the timer expires (None if no timer active).
end_time: Option<Instant>,
/// Total duration in seconds configured for the timer.
duration_seconds: u32,
}
impl Default for SleepTimer {
fn default() -> Self {
Self {
end_time: None,
duration_seconds: 0,
}
}
}
impl SleepTimer {
/// Maximum timer duration in seconds (2 hours).
pub const MAX_DURATION: u32 = 7200;
/// Creates a new inactive timer.
pub fn new() -> Self {
Self::default()
}
/// Returns the remaining seconds, or None if no timer is active.
pub fn remaining_seconds(&self) -> Option<u32> {
self.end_time.map(|end| {
let now = Instant::now();
if now >= end {
0
} else {
end.duration_since(now).as_secs() as u32
}
})
}
/// Returns the configured duration in seconds.
pub fn duration_seconds(&self) -> u32 {
self.duration_seconds
}
/// Returns true if the timer is active.
pub fn is_active(&self) -> bool {
self.end_time.is_some()
}
/// Returns true if the timer has expired.
pub fn is_expired(&self) -> bool {
self.end_time
.map(|end| Instant::now() >= end)
.unwrap_or(false)
}
/// Starts or restarts the timer with the given duration in seconds.
/// Maximum duration is 2 hours (7200 seconds).
///
/// # Errors
/// Returns an error if:
/// - duration is 0
/// - duration exceeds MAX_DURATION (7200 seconds)
pub fn start(&mut self, duration_seconds: u32) -> Result<(), String> {
if duration_seconds == 0 {
return Err("Duration must be greater than 0".to_string());
}
if duration_seconds > Self::MAX_DURATION {
return Err(format!(
"Duration cannot exceed {} seconds (2 hours)",
Self::MAX_DURATION
));
}
self.duration_seconds = duration_seconds;
self.end_time =
Some(Instant::now() + std::time::Duration::from_secs(duration_seconds as u64));
Ok(())
}
/// Updates the timer duration. Resets the timer to the new duration from now.
///
/// # Errors
/// Returns an error if:
/// - duration is 0
/// - duration exceeds MAX_DURATION (7200 seconds)
pub fn update(&mut self, duration_seconds: u32) -> Result<(), String> {
if duration_seconds == 0 {
return Err("Duration must be greater than 0".to_string());
}
if duration_seconds > Self::MAX_DURATION {
return Err(format!(
"Duration cannot exceed {} seconds (2 hours)",
Self::MAX_DURATION
));
}
self.duration_seconds = duration_seconds;
self.end_time =
Some(Instant::now() + std::time::Duration::from_secs(duration_seconds as u64));
Ok(())
}
/// Cancels the timer.
pub fn cancel(&mut self) {
self.end_time = None;
self.duration_seconds = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
#[test]
fn test_timer_creation() {
let timer = SleepTimer::new();
assert!(!timer.is_active());
assert_eq!(timer.remaining_seconds(), None);
assert_eq!(timer.duration_seconds(), 0);
}
#[test]
fn test_timer_start() {
let mut timer = SleepTimer::new();
assert!(timer.start(60).is_ok());
assert!(timer.is_active());
assert_eq!(timer.duration_seconds(), 60);
let remaining = timer.remaining_seconds().unwrap();
assert!(remaining <= 60 && remaining > 58); // Account for execution time
}
#[test]
fn test_timer_validation() {
let mut timer = SleepTimer::new();
// Zero duration should fail
assert!(timer.start(0).is_err());
// Exceeding max duration should fail
assert!(timer.start(SleepTimer::MAX_DURATION + 1).is_err());
// Valid durations should succeed
assert!(timer.start(1).is_ok());
assert!(timer.start(SleepTimer::MAX_DURATION).is_ok());
}
#[test]
fn test_timer_expiration() {
let mut timer = SleepTimer::new();
timer.start(1).unwrap();
assert!(timer.is_active());
assert!(!timer.is_expired());
thread::sleep(Duration::from_millis(1100));
assert!(timer.is_expired());
assert_eq!(timer.remaining_seconds().unwrap(), 0);
}
#[test]
fn test_timer_update() {
let mut timer = SleepTimer::new();
timer.start(60).unwrap();
thread::sleep(Duration::from_millis(500));
// Update should reset the timer
timer.update(30).unwrap();
assert_eq!(timer.duration_seconds(), 30);
let remaining = timer.remaining_seconds().unwrap();
assert!(remaining <= 30 && remaining > 28);
}
#[test]
fn test_timer_cancel() {
let mut timer = SleepTimer::new();
timer.start(60).unwrap();
assert!(timer.is_active());
timer.cancel();
assert!(!timer.is_active());
assert_eq!(timer.remaining_seconds(), None);
assert_eq!(timer.duration_seconds(), 0);
}
}

View File

@@ -282,6 +282,26 @@ pub struct TransferQueueRequest {
pub destination_renderer_id: String,
}
/// Requête pour démarrer ou mettre à jour le sleep timer
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct SleepTimerRequest {
/// Durée en secondes (maximum 7200 = 2 heures)
pub duration_seconds: u32,
}
/// État du sleep timer
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SleepTimerState {
/// Timer actif ou non
pub active: bool,
/// Durée totale configurée en secondes
pub duration_seconds: u32,
/// Secondes restantes (None si timer inactif)
pub remaining_seconds: Option<u32>,
}
/// Réponse générique de succès
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -426,6 +446,10 @@ GET /control/servers/{server_id}/containers/{container_id}
crate::pmoserver_ext::volume_up_renderer,
crate::pmoserver_ext::volume_down_renderer,
crate::pmoserver_ext::toggle_mute_renderer,
crate::pmoserver_ext::start_sleep_timer,
crate::pmoserver_ext::update_sleep_timer,
crate::pmoserver_ext::cancel_sleep_timer,
crate::pmoserver_ext::get_sleep_timer_state,
crate::pmoserver_ext::attach_playlist_binding,
crate::pmoserver_ext::detach_playlist_binding,
crate::pmoserver_ext::play_content,
@@ -456,6 +480,8 @@ GET /control/servers/{server_id}/containers/{container_id}
SeekQueueRequest,
SeekRequest,
TransferQueueRequest,
SleepTimerRequest,
SleepTimerState,
SuccessResponse,
ErrorResponse,
)),

View File

@@ -14,7 +14,8 @@ use crate::openapi::{
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot,
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
SeekQueueRequest, SeekRequest, SuccessResponse, TransferQueueRequest, VolumeSetRequest,
SeekQueueRequest, SeekRequest, SleepTimerRequest, SleepTimerState, SuccessResponse,
TransferQueueRequest, VolumeSetRequest,
};
#[cfg(feature = "pmoserver")]
use crate::queue::PlaybackItem;
@@ -1133,6 +1134,215 @@ async fn toggle_mute_renderer(
}))
}
// ============================================================================
// HANDLERS - SLEEP TIMER
// ============================================================================
/// POST /control/renderers/{renderer_id}/timer/start - Démarre le sleep timer
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
path = "/renderers/{renderer_id}/timer/start",
params(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
request_body = SleepTimerRequest,
responses(
(status = 200, description = "Timer démarré", body = SleepTimerState),
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
(status = 400, description = "Durée invalide", body = ErrorResponse),
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
),
tag = "control"
)]
async fn start_sleep_timer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
Json(req): Json<SleepTimerRequest>,
) -> Result<Json<SleepTimerState>, (StatusCode, Json<ErrorResponse>)> {
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
.music_renderer_by_id(&rid)
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Renderer {} not found", renderer_id),
}),
)
})?;
// Start the timer
let remaining = renderer
.start_sleep_timer(req.duration_seconds)
.map_err(|e| {
warn!(
"Failed to start sleep timer for renderer {}: {}",
renderer_id, e
);
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: format!("Failed to start timer: {}", e),
}),
)
})?;
// Note: TimerStarted event will be emitted by the timer watchdog thread
Ok(Json(SleepTimerState {
active: true,
duration_seconds: req.duration_seconds,
remaining_seconds: Some(remaining),
}))
}
/// POST /control/renderers/{renderer_id}/timer/update - Met à jour la durée du timer
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
path = "/renderers/{renderer_id}/timer/update",
params(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
request_body = SleepTimerRequest,
responses(
(status = 200, description = "Timer mis à jour", body = SleepTimerState),
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
(status = 400, description = "Durée invalide", body = ErrorResponse),
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
),
tag = "control"
)]
async fn update_sleep_timer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
Json(req): Json<SleepTimerRequest>,
) -> Result<Json<SleepTimerState>, (StatusCode, Json<ErrorResponse>)> {
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
.music_renderer_by_id(&rid)
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Renderer {} not found", renderer_id),
}),
)
})?;
// Update the timer
let remaining = renderer
.update_sleep_timer(req.duration_seconds)
.map_err(|e| {
warn!(
"Failed to update sleep timer for renderer {}: {}",
renderer_id, e
);
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: format!("Failed to update timer: {}", e),
}),
)
})?;
// Note: TimerUpdated event will be emitted by the timer watchdog thread
Ok(Json(SleepTimerState {
active: true,
duration_seconds: req.duration_seconds,
remaining_seconds: Some(remaining),
}))
}
/// POST /control/renderers/{renderer_id}/timer/cancel - Annule le sleep timer
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
path = "/renderers/{renderer_id}/timer/cancel",
params(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
responses(
(status = 200, description = "Timer annulé", body = SuccessResponse),
(status = 404, description = "Renderer non trouvé", body = ErrorResponse)
),
tag = "control"
)]
async fn cancel_sleep_timer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
.music_renderer_by_id(&rid)
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Renderer {} not found", renderer_id),
}),
)
})?;
// Cancel the timer
renderer.cancel_sleep_timer();
// Note: TimerCancelled event will be emitted by the timer watchdog thread
Ok(Json(SuccessResponse {
message: "Sleep timer cancelled".to_string(),
}))
}
/// GET /control/renderers/{renderer_id}/timer - Récupère l'état du sleep timer
#[cfg(feature = "pmoserver")]
#[utoipa::path(
get,
path = "/renderers/{renderer_id}/timer",
params(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
responses(
(status = 200, description = "État du timer", body = SleepTimerState),
(status = 404, description = "Renderer non trouvé", body = ErrorResponse)
),
tag = "control"
)]
async fn get_sleep_timer_state(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SleepTimerState>, (StatusCode, Json<ErrorResponse>)> {
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
.music_renderer_by_id(&rid)
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Renderer {} not found", renderer_id),
}),
)
})?;
let (active, duration_seconds, remaining_seconds) = renderer.sleep_timer_state();
Ok(Json(SleepTimerState {
active,
duration_seconds,
remaining_seconds,
}))
}
// ============================================================================
// HANDLERS - BINDING PLAYLIST
// ============================================================================
@@ -1987,6 +2197,20 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
"/renderers/{renderer_id}/mute/toggle",
post(toggle_mute_renderer),
)
// Sleep timer
.route("/renderers/{renderer_id}/timer", get(get_sleep_timer_state))
.route(
"/renderers/{renderer_id}/timer/start",
post(start_sleep_timer),
)
.route(
"/renderers/{renderer_id}/timer/update",
post(update_sleep_timer),
)
.route(
"/renderers/{renderer_id}/timer/cancel",
post(cancel_sleep_timer),
)
// Playlist binding
.route(
"/renderers/{renderer_id}/binding/attach",

View File

@@ -85,6 +85,31 @@ pub enum RendererEventPayload {
container_id: Option<String>,
timestamp: chrono::DateTime<chrono::Utc>,
},
TimerStarted {
renderer_id: String,
duration_seconds: u32,
remaining_seconds: u32,
timestamp: chrono::DateTime<chrono::Utc>,
},
TimerUpdated {
renderer_id: String,
duration_seconds: u32,
remaining_seconds: u32,
timestamp: chrono::DateTime<chrono::Utc>,
},
TimerTick {
renderer_id: String,
remaining_seconds: u32,
timestamp: chrono::DateTime<chrono::Utc>,
},
TimerExpired {
renderer_id: String,
timestamp: chrono::DateTime<chrono::Utc>,
},
TimerCancelled {
renderer_id: String,
timestamp: chrono::DateTime<chrono::Utc>,
},
Online {
renderer_id: String,
friendly_name: String,
@@ -273,6 +298,41 @@ pub async fn renderer_events_sse(
timestamp,
}
}
RendererEvent::TimerStarted { id, duration_seconds, remaining_seconds } => {
RendererEventPayload::TimerStarted {
renderer_id: id.0,
duration_seconds,
remaining_seconds,
timestamp,
}
}
RendererEvent::TimerUpdated { id, duration_seconds, remaining_seconds } => {
RendererEventPayload::TimerUpdated {
renderer_id: id.0,
duration_seconds,
remaining_seconds,
timestamp,
}
}
RendererEvent::TimerTick { id, remaining_seconds } => {
RendererEventPayload::TimerTick {
renderer_id: id.0,
remaining_seconds,
timestamp,
}
}
RendererEvent::TimerExpired { id } => {
RendererEventPayload::TimerExpired {
renderer_id: id.0,
timestamp,
}
}
RendererEvent::TimerCancelled { id } => {
RendererEventPayload::TimerCancelled {
renderer_id: id.0,
timestamp,
}
}
RendererEvent::Online { id, info } => {
RendererEventPayload::Online {
renderer_id: id.0,
@@ -647,6 +707,41 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
timestamp,
}
}
RendererEvent::TimerStarted { id, duration_seconds, remaining_seconds } => {
RendererEventPayload::TimerStarted {
renderer_id: id.0,
duration_seconds,
remaining_seconds,
timestamp,
}
}
RendererEvent::TimerUpdated { id, duration_seconds, remaining_seconds } => {
RendererEventPayload::TimerUpdated {
renderer_id: id.0,
duration_seconds,
remaining_seconds,
timestamp,
}
}
RendererEvent::TimerTick { id, remaining_seconds } => {
RendererEventPayload::TimerTick {
renderer_id: id.0,
remaining_seconds,
timestamp,
}
}
RendererEvent::TimerExpired { id } => {
RendererEventPayload::TimerExpired {
renderer_id: id.0,
timestamp,
}
}
RendererEvent::TimerCancelled { id } => {
RendererEventPayload::TimerCancelled {
renderer_id: id.0,
timestamp,
}
}
RendererEvent::Online { id, info } => {
RendererEventPayload::Online {
renderer_id: id.0,