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)
|
||||
}
|
||||
|
||||
@@ -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(®istry);
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
199
pmocontrol/src/music_renderer/sleep_timer.rs
Normal file
199
pmocontrol/src/music_renderer/sleep_timer.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user