Merge pull request 'push-mlzoossntumr' (#48) from push-mlzoossntumr into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 28m51s

Reviewed-on: #48
This commit was merged in pull request #48.
This commit is contained in:
2026-01-11 00:31:17 +01:00
19 changed files with 2560 additions and 273 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 { computed } from "vue";
import { Server, Music2 } from "lucide-vue-next"; import { Server, Music2 } from "lucide-vue-next";
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue"; import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
import TimerControl from "@/components/pmocontrol/TimerControl.vue";
import type { import type {
RendererSummary, RendererSummary,
RendererState, RendererState,
@@ -112,6 +113,11 @@ function handleRendererDrawerClick() {
</div> </div>
</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) --> <!-- Bouton pour ouvrir le drawer des renderers (droite) -->
<button <button
class="drawer-button renderer-drawer-button" class="drawer-button renderer-drawer-button"

View File

@@ -12,6 +12,7 @@ import type {
VolumeSetRequest, VolumeSetRequest,
AttachPlaylistRequest, AttachPlaylistRequest,
PlayContentRequest, PlayContentRequest,
SleepTimerState,
SuccessResponse, SuccessResponse,
ErrorResponse, ErrorResponse,
} from "./types"; } from "./types";
@@ -409,6 +410,67 @@ class PMOControlAPI {
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`, `/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 // Export singleton

View File

@@ -200,6 +200,36 @@ export type RendererEventPayload =
container_id: string | null; container_id: string | null;
timestamp: string; 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"; type: "online";
renderer_id: string; renderer_id: string;
@@ -254,3 +284,17 @@ export interface PositionInfo {
rel_time: string | null; // Format HH:MM:SS rel_time: string | null; // Format HH:MM:SS
track_duration: 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 { Ok(Self {
registry, registry,
// udn_cache, // udn_cache,
@@ -554,7 +640,7 @@ impl ControlPoint {
})?; })?;
// Get queue length before clearing // Get queue length before clearing
let removed = renderer.get_queue().upcoming_len().unwrap_or(0); let removed = renderer.upcoming_len().unwrap_or(0);
renderer.clear_queue()?; renderer.clear_queue()?;
@@ -611,11 +697,8 @@ impl ControlPoint {
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
})?; })?;
let new_len = { renderer.enqueue_items(items, mode)?;
let mut queue = renderer.get_queue_mut(); let new_len = renderer.upcoming_len()?;
queue.enqueue_items(items, mode)?;
queue.upcoming_len()?
};
debug!( debug!(
renderer = renderer_id.0.as_str(), renderer = renderer_id.0.as_str(),
@@ -703,7 +786,6 @@ impl ControlPoint {
// Get queue from renderer (works for all backends) // Get queue from renderer (works for all backends)
let queue_snapshot = renderer let queue_snapshot = renderer
.get_queue()
.queue_snapshot() .queue_snapshot()
.map_err(|e| anyhow!("Failed to get queue snapshot: {}", e))?; .map_err(|e| anyhow!("Failed to get queue snapshot: {}", e))?;
@@ -869,7 +951,7 @@ impl ControlPoint {
})?; })?;
// Use generic queue access (works for all backends) // Use generic queue access (works for all backends)
let Some((item, remaining)) = renderer.get_queue_mut().peek_current()? else { let Some((item, remaining)) = renderer.peek_current()? else {
debug!( debug!(
renderer = renderer_id.0.as_str(), renderer = renderer_id.0.as_str(),
"play_current_from_queue: queue is empty or no current item" "play_current_from_queue: queue is empty or no current item"
@@ -889,9 +971,8 @@ impl ControlPoint {
// when renderer sends Stopped event during SetAVTransportURI // when renderer sends Stopped event during SetAVTransportURI
renderer.set_playback_source(PlaybackSource::None); renderer.set_playback_source(PlaybackSource::None);
// Start playback // Start playback using play_from_queue which preserves the queue
let didl_metadata = playback_item_to_didl(&item); if let Err(err) = renderer.play_from_queue() {
if let Err(err) = renderer.play_uri(&item.uri, &didl_metadata) {
error!( error!(
renderer = renderer_id.0.as_str(), renderer = renderer_id.0.as_str(),
error = %err, error = %err,
@@ -922,61 +1003,50 @@ impl ControlPoint {
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
})?; })?;
// Dequeue next item // Check if queue is empty before trying to play next
let Some((item, remaining_after)) = renderer.get_queue_mut().dequeue_next()? else { if renderer.len()? == 0 {
debug!( debug!(
renderer = renderer_id.0.as_str(), renderer = renderer_id.0.as_str(),
"play_next_from_queue: queue is empty" "play_next_from_queue: queue is empty"
); );
renderer.set_playback_source(PlaybackSource::None); renderer.set_playback_source(PlaybackSource::None);
return Ok(()); return Ok(());
}; }
debug!(
renderer = renderer_id.0.as_str(),
queue_after = remaining_after,
uri = item.uri.as_str(),
"Dequeued next playback item"
);
// Temporarily disable auto-advance to prevent race condition // Temporarily disable auto-advance to prevent race condition
// when renderer sends Stopped event during SetAVTransportURI
renderer.set_playback_source(PlaybackSource::None); renderer.set_playback_source(PlaybackSource::None);
// Start playback // Use the backend's play_next which handles queue advancement correctly for each backend type
let didl_metadata = playback_item_to_didl(&item); if let Err(err) = renderer.play_next_from_queue() {
if let Err(err) = renderer.play_uri(&item.uri, &didl_metadata) {
error!( error!(
renderer = renderer_id.0.as_str(), renderer = renderer_id.0.as_str(),
error = %err, error = %err,
"Failed to start playback for queued item" "Failed to play next item from queue"
); );
// Try to requeue the item
let _ = renderer
.get_queue_mut()
.enqueue_items(vec![item.clone()], EnqueueMode::InsertAfterCurrent);
renderer.set_playback_source(PlaybackSource::None); renderer.set_playback_source(PlaybackSource::None);
return Err(err); return Err(err);
} }
// Save metadata for current track // Get current item metadata for tracking
let metadata = playback_item_track_metadata(&item); if let Some((item, _)) = renderer.peek_current()? {
renderer.set_last_metadata(Some(metadata)); let metadata = playback_item_track_metadata(&item);
renderer.set_last_metadata(Some(metadata));
}
renderer.set_playback_source(PlaybackSource::FromQueue); renderer.set_playback_source(PlaybackSource::FromQueue);
debug!( debug!(
renderer = renderer_id.0.as_str(), renderer = renderer_id.0.as_str(),
queue_len = remaining_after, "Playing next item from queue"
"Started playback from queue"
); );
// Prefetch next track if supported // Prefetch next track if supported
self.prefetch_next_track(&renderer, renderer_id); self.prefetch_next_track(&renderer, renderer_id);
// Emit QueueUpdated event // Emit QueueUpdated event
let queue_length = renderer.len().unwrap_or(0);
self.emit_renderer_event(RendererEvent::QueueUpdated { self.emit_renderer_event(RendererEvent::QueueUpdated {
id: renderer_id.clone(), id: renderer_id.clone(),
queue_length: remaining_after, queue_length,
}); });
Ok(()) Ok(())
@@ -990,7 +1060,7 @@ impl ControlPoint {
} }
// Get the next item from the queue using peek_current // Get the next item from the queue using peek_current
let Ok(Some((_, remaining))) = renderer.get_queue_mut().peek_current() else { let Ok(Some((_, remaining))) = renderer.peek_current() else {
return; return;
}; };
@@ -998,7 +1068,7 @@ impl ControlPoint {
return; return;
} }
let queue_snapshot = match renderer.get_queue().queue_snapshot() { let queue_snapshot = match renderer.queue_snapshot() {
Ok(snapshot) => snapshot, Ok(snapshot) => snapshot,
Err(_) => return, Err(_) => return,
}; };
@@ -1033,37 +1103,26 @@ impl ControlPoint {
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
})?; })?;
// Set queue index // Check if index is valid
renderer.get_queue_mut().set_index(Some(index))?; if index >= renderer.len()? {
let Some((item, remaining)) = renderer.get_queue_mut().peek_current()? else {
debug!( debug!(
renderer = renderer_id.0.as_str(), renderer = renderer_id.0.as_str(),
index, "play_queue_index: no item at index" index, "play_queue_index: index out of bounds"
); );
renderer.set_playback_source(PlaybackSource::None); renderer.set_playback_source(PlaybackSource::None);
return Ok(()); return Ok(());
}; }
debug!(
renderer = renderer_id.0.as_str(),
index,
queue_len = remaining + 1,
uri = item.uri.as_str(),
"Playing item at index from queue"
);
// Temporarily disable auto-advance to prevent race condition // Temporarily disable auto-advance to prevent race condition
renderer.set_playback_source(PlaybackSource::None); renderer.set_playback_source(PlaybackSource::None);
// Start playback // Use the backend's play_from_index which handles everything correctly
let didl_metadata = playback_item_to_didl(&item); if let Err(err) = renderer.play_from_index(index) {
if let Err(err) = renderer.play_uri(&item.uri, &didl_metadata) {
error!( error!(
renderer = renderer_id.0.as_str(), renderer = renderer_id.0.as_str(),
index, index,
error = %err, error = %err,
"Failed to start playback for queued item at index" "Failed to play from queue index"
); );
renderer.set_playback_source(PlaybackSource::None); renderer.set_playback_source(PlaybackSource::None);
return Err(err); return Err(err);
@@ -1071,14 +1130,14 @@ impl ControlPoint {
info!( info!(
renderer = renderer_id.0.as_str(), renderer = renderer_id.0.as_str(),
index, index, "Queue playback started at index"
uri = item.uri.as_str(),
"Queue playback started at index"
); );
// Save metadata // Get current item metadata for tracking
let metadata = playback_item_track_metadata(&item); if let Some((item, _)) = renderer.peek_current()? {
renderer.set_last_metadata(Some(metadata)); let metadata = playback_item_track_metadata(&item);
renderer.set_last_metadata(Some(metadata));
}
renderer.set_playback_source(PlaybackSource::FromQueue); renderer.set_playback_source(PlaybackSource::FromQueue);
Ok(()) Ok(())
@@ -1184,7 +1243,7 @@ impl ControlPoint {
// Clear the local queue (detach binding + clear runtime queue structure) // Clear the local queue (detach binding + clear runtime queue structure)
self.detach_playlist_binding(renderer_id, "attach_new_playlist"); self.detach_playlist_binding(renderer_id, "attach_new_playlist");
renderer.get_queue_mut().clear_queue()?; renderer.clear_queue()?;
debug!( debug!(
renderer = renderer_id.0.as_str(), renderer = renderer_id.0.as_str(),

View File

@@ -434,6 +434,26 @@ pub enum RendererEvent {
id: DeviceId, id: DeviceId,
binding: Option<PlaylistBinding>, 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 { Online {
id: DeviceId, id: DeviceId,
info: DeviceBasicInfo, info: DeviceBasicInfo,

View File

@@ -1,3 +1,4 @@
use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use serde::Deserialize; use serde::Deserialize;
@@ -13,10 +14,13 @@ use crate::linkplay_client::extract_linkplay_host;
use crate::model::{PlaybackState, RendererInfo}; use crate::model::{PlaybackState, RendererInfo};
use crate::music_renderer::RendererFromMediaRendererInfo; use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::music_renderer::capabilities::{ use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
}; };
use crate::music_renderer::musicrenderer::MusicRendererBackend; use crate::music_renderer::musicrenderer::MusicRendererBackend;
use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict}; use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict};
use crate::queue::MusicQueue;
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
/// Raw response from Arylic MCU+PINFGET command /// Raw response from Arylic MCU+PINFGET command
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -40,6 +44,7 @@ pub struct ArylicTcpRenderer {
host: String, host: String,
port: u16, port: u16,
timeout: Duration, timeout: Duration,
queue: Arc<Mutex<MusicQueue>>,
} }
impl ArylicTcpRenderer { impl ArylicTcpRenderer {
@@ -111,10 +116,13 @@ impl RendererFromMediaRendererInfo for ArylicTcpRenderer {
)) ))
})?; })?;
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
Ok(Self { Ok(Self {
host, host,
port: ARYLIC_TCP_PORT, port: ARYLIC_TCP_PORT,
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
queue,
}) })
} }
@@ -209,6 +217,135 @@ impl PlaybackPosition for ArylicTcpRenderer {
} }
} }
impl RendererBackend for ArylicTcpRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
&self.queue
}
}
impl QueueTransportControl for ArylicTcpRenderer {
fn play_from_queue(&self) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().unwrap();
let current_index = match queue.current_index()? {
Some(idx) => idx,
None => {
if queue.len()? > 0 {
queue.set_index(Some(0))?;
0
} else {
return Err(ControlPointError::QueueError("Queue is empty".into()));
}
}
};
let item = queue
.get_item(current_index)?
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
let uri = item.uri.clone();
drop(queue);
self.play_uri(&uri, "")
}
fn play_next(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.advance()? {
return Err(ControlPointError::QueueError("No next track".into()));
}
}
self.play_from_queue()
}
fn play_previous(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.rewind()? {
return Err(ControlPointError::QueueError("No previous track".into()));
}
}
self.play_from_queue()
}
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
queue.set_index(Some(index))?;
}
self.play_from_queue()
}
}
impl QueueBackend for ArylicTcpRenderer {
fn len(&self) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().len()
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
self.queue.lock().unwrap().track_ids()
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().id_to_position(id)
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
self.queue.lock().unwrap().position_to_id(id)
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
self.queue.lock().unwrap().current_track()
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
self.queue.lock().unwrap().current_index()
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
self.queue.lock().unwrap().queue_snapshot()
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().set_index(index)
}
fn replace_queue(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.replace_queue(items, current_index)
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().sync_queue(items)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
self.queue.lock().unwrap().get_item(index)
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().replace_item(index, item)
}
fn enqueue_items(
&mut self,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().enqueue_items(items, mode)
}
}
#[derive(Debug)] #[derive(Debug)]
struct ArylicPlaybackInfo { struct ArylicPlaybackInfo {
status_raw: String, status_raw: String,

View File

@@ -1,8 +1,36 @@
// pmocontrol/src/capabilities.rs // pmocontrol/src/capabilities.rs
use anyhow::Result; use anyhow::Result;
use std::sync::{Arc, Mutex};
use crate::queue::MusicQueue;
use crate::{errors::ControlPointError, model::PlaybackState}; use crate::{errors::ControlPointError, model::PlaybackState};
/// Backend-specific operations for renderers.
///
/// This trait provides access to backend-specific resources like the queue.
pub trait RendererBackend {
/// Returns a reference to the queue associated with this backend.
fn queue(&self) -> &Arc<Mutex<MusicQueue>>;
}
/// Queue-aware transport control operations.
///
/// These operations combine queue management with transport control,
/// allowing navigation (next/previous) and track selection from the queue.
pub trait QueueTransportControl {
/// Play the next track from the queue.
fn play_next(&self) -> Result<(), ControlPointError>;
/// Play the previous track from the queue.
fn play_previous(&self) -> Result<(), ControlPointError>;
/// Play from the queue at the current index (or initialize to 0 if not set).
fn play_from_queue(&self) -> Result<(), ControlPointError>;
/// Play from a specific index in the queue.
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError>;
}
/// Logical playback position across backends. /// Logical playback position across backends.
/// ///
/// Times peuvent être soit en secondes, soit en "HH:MM:SS" selon ce que /// Times peuvent être soit en secondes, soit en "HH:MM:SS" selon ce que

View File

@@ -17,16 +17,19 @@ use std::thread::JoinHandle;
use tracing::debug; use tracing::debug;
use crate::DeviceIdentity; use crate::DeviceIdentity;
use crate::music_renderer::capabilities::{ use crate::discovery::chromecast_discovery::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, extract_host_from_location, extract_port_from_location,
VolumeControl,
}; };
use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict};
use crate::discovery::chromecast_discovery::{extract_host_from_location, extract_port_from_location};
use crate::errors::ControlPointError; use crate::errors::ControlPointError;
use crate::model::{PlaybackState, RendererInfo}; use crate::model::{PlaybackState, RendererInfo};
use crate::music_renderer::RendererFromMediaRendererInfo; use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
};
use crate::music_renderer::musicrenderer::MusicRendererBackend; use crate::music_renderer::musicrenderer::MusicRendererBackend;
use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict};
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
use rust_cast::{ use rust_cast::{
CastDevice, ChannelMessage, CastDevice, ChannelMessage,
@@ -55,6 +58,7 @@ pub struct ChromecastRenderer {
/// Handle to the active heartbeat thread, if any. /// Handle to the active heartbeat thread, if any.
/// Wrapped in Arc<Mutex> to allow cloning and proper thread lifecycle management. /// Wrapped in Arc<Mutex> to allow cloning and proper thread lifecycle management.
thread_handle: Arc<Mutex<Option<JoinHandle<()>>>>, thread_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
queue: Arc<Mutex<MusicQueue>>,
} }
impl std::fmt::Debug for ChromecastRenderer { impl std::fmt::Debug for ChromecastRenderer {
@@ -88,20 +92,22 @@ fn connect_to_device<'a>(host: &'a str, port: u16) -> Result<CastDevice<'a>, Con
// Ensure rustls crypto provider is initialized before any TLS connection // Ensure rustls crypto provider is initialized before any TLS connection
ensure_crypto_provider_initialized(); ensure_crypto_provider_initialized();
let device = CastDevice::connect_without_host_verification(host, port) let device = CastDevice::connect_without_host_verification(host, port).map_err(|e| {
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to Chromecast: {}", e)))?; ControlPointError::ChromecastError(format!("Failed to connect to Chromecast: {}", e))
})?;
device device
.connection .connection
.connect(DEFAULT_DESTINATION_ID.to_string()) .connect(DEFAULT_DESTINATION_ID.to_string())
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect channel: {}", e)))?; .map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to connect channel: {}", e))
})?;
// Send initial gre to establish heartbeat communication // Send initial gre to establish heartbeat communication
// This is critical per rust_caster.rs example // This is critical per rust_caster.rs example
device device.heartbeat.ping().map_err(|e| {
.heartbeat ControlPointError::ChromecastError(format!("Failed to send initial heartbeat ping: {}", e))
.ping() })?;
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to send initial heartbeat ping: {}", e)))?;
Ok(device) Ok(device)
} }
@@ -116,7 +122,6 @@ fn map_player_state(player_state: &CastPlayerState) -> PlaybackState {
} }
} }
impl RendererFromMediaRendererInfo for ChromecastRenderer { impl RendererFromMediaRendererInfo for ChromecastRenderer {
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> { fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
tracing::info!( tracing::info!(
@@ -125,13 +130,18 @@ impl RendererFromMediaRendererInfo for ChromecastRenderer {
info.friendly_name() info.friendly_name()
); );
let host = extract_host_from_location(info.location()) let host = extract_host_from_location(info.location()).ok_or_else(|| {
.ok_or_else(|| ControlPointError::ChromecastError(format!("Invalid Chromecast location: {}", info.location())))?; ControlPointError::ChromecastError(format!(
"Invalid Chromecast location: {}",
info.location()
))
})?;
let port = extract_port_from_location(info.location()).unwrap_or(DEFAULT_CHROMECAST_PORT); let port = extract_port_from_location(info.location()).unwrap_or(DEFAULT_CHROMECAST_PORT);
let stop_signal = Arc::new(Mutex::new(false)); let stop_signal = Arc::new(Mutex::new(false));
let thread_handle = Arc::new(Mutex::new(None)); let thread_handle = Arc::new(Mutex::new(None));
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
tracing::info!( tracing::info!(
"ChromecastRenderer created for {} with host={} port={}", "ChromecastRenderer created for {} with host={} port={}",
@@ -145,13 +155,13 @@ impl RendererFromMediaRendererInfo for ChromecastRenderer {
port, port,
stop_signal, stop_signal,
thread_handle, thread_handle,
queue,
}) })
} }
fn to_backend(self) -> MusicRendererBackend { fn to_backend(self) -> MusicRendererBackend {
MusicRendererBackend::Chromecast(self) MusicRendererBackend::Chromecast(self)
} }
} }
impl TransportControl for ChromecastRenderer { impl TransportControl for ChromecastRenderer {
@@ -308,10 +318,9 @@ impl TransportControl for ChromecastRenderer {
let device = connect_to_device(&self.host, self.port)?; let device = connect_to_device(&self.host, self.port)?;
// Get receiver status to find the active app // Get receiver status to find the active app
let status = device let status = device.receiver.get_status().map_err(|e| {
.receiver ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
.get_status() })?;
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
let app = status let app = status
.applications .applications
@@ -322,13 +331,17 @@ impl TransportControl for ChromecastRenderer {
device device
.connection .connection
.connect(app.transport_id.as_str()) .connect(app.transport_id.as_str())
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?; .map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
})?;
// Get media status // Get media status
let media_status = device let media_status = device
.media .media
.get_status(app.transport_id.as_str(), None) .get_status(app.transport_id.as_str(), None)
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?; .map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
})?;
let media_entry = media_status let media_entry = media_status
.entries .entries
@@ -349,10 +362,9 @@ impl TransportControl for ChromecastRenderer {
let device = connect_to_device(&self.host, self.port)?; let device = connect_to_device(&self.host, self.port)?;
let status = device let status = device.receiver.get_status().map_err(|e| {
.receiver ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
.get_status() })?;
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
let app = status let app = status
.applications .applications
@@ -362,12 +374,16 @@ impl TransportControl for ChromecastRenderer {
device device
.connection .connection
.connect(app.transport_id.as_str()) .connect(app.transport_id.as_str())
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?; .map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
})?;
let media_status = device let media_status = device
.media .media
.get_status(app.transport_id.as_str(), None) .get_status(app.transport_id.as_str(), None)
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?; .map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
})?;
let media_entry = media_status let media_entry = media_status
.entries .entries
@@ -397,10 +413,9 @@ impl TransportControl for ChromecastRenderer {
// Also send stop command to the device // Also send stop command to the device
let device = connect_to_device(&self.host, self.port)?; let device = connect_to_device(&self.host, self.port)?;
let status = device let status = device.receiver.get_status().map_err(|e| {
.receiver ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
.get_status() })?;
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
let app = status let app = status
.applications .applications
@@ -410,12 +425,16 @@ impl TransportControl for ChromecastRenderer {
device device
.connection .connection
.connect(app.transport_id.as_str()) .connect(app.transport_id.as_str())
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?; .map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
})?;
let media_status = device let media_status = device
.media .media
.get_status(app.transport_id.as_str(), None) .get_status(app.transport_id.as_str(), None)
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?; .map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
})?;
let media_entry = media_status let media_entry = media_status
.entries .entries
@@ -437,10 +456,9 @@ impl TransportControl for ChromecastRenderer {
let device = connect_to_device(&self.host, self.port)?; let device = connect_to_device(&self.host, self.port)?;
let status = device let status = device.receiver.get_status().map_err(|e| {
.receiver ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
.get_status() })?;
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
let app = status let app = status
.applications .applications
@@ -450,12 +468,16 @@ impl TransportControl for ChromecastRenderer {
device device
.connection .connection
.connect(app.transport_id.as_str()) .connect(app.transport_id.as_str())
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?; .map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
})?;
let media_status = device let media_status = device
.media .media
.get_status(app.transport_id.as_str(), None) .get_status(app.transport_id.as_str(), None)
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?; .map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
})?;
let media_entry = media_status let media_entry = media_status
.entries .entries
@@ -481,10 +503,9 @@ impl PlaybackStatus for ChromecastRenderer {
let device = connect_to_device(&self.host, self.port)?; let device = connect_to_device(&self.host, self.port)?;
// Get receiver status to find the active app // Get receiver status to find the active app
let status = device let status = device.receiver.get_status().map_err(|e| {
.receiver ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
.get_status() })?;
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
// If no app is running, return NoMedia // If no app is running, return NoMedia
let app = match status.applications.first() { let app = match status.applications.first() {
@@ -496,13 +517,17 @@ impl PlaybackStatus for ChromecastRenderer {
device device
.connection .connection
.connect(app.transport_id.as_str()) .connect(app.transport_id.as_str())
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?; .map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
})?;
// Get media status // Get media status
let media_status = device let media_status = device
.media .media
.get_status(app.transport_id.as_str(), None) .get_status(app.transport_id.as_str(), None)
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?; .map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
})?;
// If no media entry, return NoMedia // If no media entry, return NoMedia
let media_entry = match media_status.entries.first() { let media_entry = match media_status.entries.first() {
@@ -519,10 +544,9 @@ impl PlaybackPosition for ChromecastRenderer {
let device = connect_to_device(&self.host, self.port)?; let device = connect_to_device(&self.host, self.port)?;
// Get receiver status to find the active app // Get receiver status to find the active app
let status = device let status = device.receiver.get_status().map_err(|e| {
.receiver ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
.get_status() })?;
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
let app = status let app = status
.applications .applications
@@ -533,13 +557,17 @@ impl PlaybackPosition for ChromecastRenderer {
device device
.connection .connection
.connect(app.transport_id.as_str()) .connect(app.transport_id.as_str())
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?; .map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
})?;
// Get media status // Get media status
let media_status = device let media_status = device
.media .media
.get_status(app.transport_id.as_str(), None) .get_status(app.transport_id.as_str(), None)
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?; .map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
})?;
let media_entry = media_status let media_entry = media_status
.entries .entries
@@ -635,10 +663,9 @@ impl VolumeControl for ChromecastRenderer {
fn volume(&self) -> Result<u16, ControlPointError> { fn volume(&self) -> Result<u16, ControlPointError> {
let device = connect_to_device(&self.host, self.port)?; let device = connect_to_device(&self.host, self.port)?;
let status = device let status = device.receiver.get_status().map_err(|e| {
.receiver ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
.get_status() })?;
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
if let Some(level) = status.volume.level { if let Some(level) = status.volume.level {
Ok((level * 100.0) as u16) Ok((level * 100.0) as u16)
@@ -653,10 +680,9 @@ impl VolumeControl for ChromecastRenderer {
let device = connect_to_device(&self.host, self.port)?; let device = connect_to_device(&self.host, self.port)?;
let level = (volume as f32) / 100.0; let level = (volume as f32) / 100.0;
device device.receiver.set_volume(level).map_err(|e| {
.receiver ControlPointError::ChromecastError(format!("Failed to set volume: {}", e))
.set_volume(level) })?;
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to set volume: {}", e)))?;
Ok(()) Ok(())
} }
@@ -664,10 +690,9 @@ impl VolumeControl for ChromecastRenderer {
fn mute(&self) -> Result<bool, ControlPointError> { fn mute(&self) -> Result<bool, ControlPointError> {
let device = connect_to_device(&self.host, self.port)?; let device = connect_to_device(&self.host, self.port)?;
let status = device let status = device.receiver.get_status().map_err(|e| {
.receiver ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
.get_status() })?;
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
Ok(status.volume.muted.unwrap_or(false)) Ok(status.volume.muted.unwrap_or(false))
} }
@@ -677,11 +702,139 @@ impl VolumeControl for ChromecastRenderer {
let device = connect_to_device(&self.host, self.port)?; let device = connect_to_device(&self.host, self.port)?;
device device.receiver.set_volume(mute).map_err(|e| {
.receiver ControlPointError::ChromecastError(format!("Failed to set mute: {}", e))
.set_volume(mute) })?;
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to set mute: {}", e)))?;
Ok(()) Ok(())
} }
} }
impl RendererBackend for ChromecastRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
&self.queue
}
}
impl QueueTransportControl for ChromecastRenderer {
fn play_from_queue(&self) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().unwrap();
let current_index = match queue.current_index()? {
Some(idx) => idx,
None => {
if queue.len()? > 0 {
queue.set_index(Some(0))?;
0
} else {
return Err(ControlPointError::QueueError("Queue is empty".into()));
}
}
};
let item = queue
.get_item(current_index)?
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
let uri = item.uri.clone();
drop(queue);
self.play_uri(&uri, "")
}
fn play_next(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.advance()? {
return Err(ControlPointError::QueueError("No next track".into()));
}
}
self.play_from_queue()
}
fn play_previous(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.rewind()? {
return Err(ControlPointError::QueueError("No previous track".into()));
}
}
self.play_from_queue()
}
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
queue.set_index(Some(index))?;
}
self.play_from_queue()
}
}
impl QueueBackend for ChromecastRenderer {
fn len(&self) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().len()
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
self.queue.lock().unwrap().track_ids()
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().id_to_position(id)
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
self.queue.lock().unwrap().position_to_id(id)
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
self.queue.lock().unwrap().current_track()
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
self.queue.lock().unwrap().current_index()
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
self.queue.lock().unwrap().queue_snapshot()
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().set_index(index)
}
fn replace_queue(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.replace_queue(items, current_index)
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().sync_queue(items)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
self.queue.lock().unwrap().get_item(index)
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().replace_item(index, item)
}
fn enqueue_items(
&mut self,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().enqueue_items(items, mode)
}
}

View File

@@ -1,28 +1,33 @@
use std::fmt; use std::fmt;
use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use ureq::Agent; use ureq::Agent;
use crate::DeviceIdentity; use crate::DeviceIdentity;
use crate::linkplay_client::{LinkPlayStatus, build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode};
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
VolumeControl,
};
use crate::music_renderer::time_utils::{parse_hhmmss_strict};
use crate::errors::ControlPointError; use crate::errors::ControlPointError;
use crate::model::{RendererInfo, PlaybackState}; use crate::linkplay_client::{
LinkPlayStatus, build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode,
};
use crate::model::{PlaybackState, RendererInfo};
use crate::music_renderer::RendererFromMediaRendererInfo; use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
};
use crate::music_renderer::musicrenderer::MusicRendererBackend; use crate::music_renderer::musicrenderer::MusicRendererBackend;
use crate::music_renderer::time_utils::parse_hhmmss_strict;
use crate::queue::MusicQueue;
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3; const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
/// Renderer backend for devices exposing the LinkPlay HTTP API. /// Renderer backend for devices exposing the LinkPlay HTTP API.
#[derive(Clone)] #[derive(Clone)]
pub struct LinkPlayRenderer { pub struct LinkPlayRenderer {
host: String, host: String,
timeout: Duration, timeout: Duration,
queue: Arc<Mutex<MusicQueue>>,
} }
impl fmt::Debug for LinkPlayRenderer { impl fmt::Debug for LinkPlayRenderer {
@@ -34,7 +39,6 @@ impl fmt::Debug for LinkPlayRenderer {
} }
impl LinkPlayRenderer { impl LinkPlayRenderer {
fn agent(&self) -> Agent { fn agent(&self) -> Agent {
build_agent(self.timeout) build_agent(self.timeout)
} }
@@ -44,10 +48,12 @@ impl LinkPlayRenderer {
"http://{}/httpapi.asp?command=setPlayerCmd:{}", "http://{}/httpapi.asp?command=setPlayerCmd:{}",
self.host, command self.host, command
); );
self.agent() self.agent().get(&url).call().map_err(|_| {
.get(&url) ControlPointError::ArilycTcpError(format!(
.call() "LinkPlay command {} failed for {}",
.map_err(|_| ControlPointError::ArilycTcpError(format!("LinkPlay command {} failed for {}", command, self.host)))?; command, self.host
))
})?;
Ok(()) Ok(())
} }
@@ -58,20 +64,25 @@ impl LinkPlayRenderer {
impl RendererFromMediaRendererInfo for LinkPlayRenderer { impl RendererFromMediaRendererInfo for LinkPlayRenderer {
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> { fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
let host = extract_linkplay_host(&info.location()) let host = extract_linkplay_host(&info.location()).ok_or_else(|| {
.ok_or_else(|| ControlPointError::LinkPlayError(format!("Renderer {} has no valid LOCATION host", info.udn())))?; ControlPointError::LinkPlayError(format!(
"Renderer {} has no valid LOCATION host",
info.udn()
))
})?;
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
Ok(Self { Ok(Self {
host, host,
timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS), timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS),
queue,
}) })
} }
fn to_backend(self) -> MusicRendererBackend { fn to_backend(self) -> MusicRendererBackend {
MusicRendererBackend::LinkPlay(self) MusicRendererBackend::LinkPlay(self)
} }
} }
impl TransportControl for LinkPlayRenderer { impl TransportControl for LinkPlayRenderer {
@@ -129,6 +140,131 @@ impl PlaybackPosition for LinkPlayRenderer {
} }
} }
impl RendererBackend for LinkPlayRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
&self.queue
}
}
impl QueueTransportControl for LinkPlayRenderer {
fn play_from_queue(&self) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().unwrap();
let current_index = match queue.current_index()? {
Some(idx) => idx,
None => {
if queue.len()? > 0 {
queue.set_index(Some(0))?;
0
} else {
return Err(ControlPointError::QueueError("Queue is empty".into()));
}
}
};
let item = queue
.get_item(current_index)?
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
let uri = item.uri.clone();
drop(queue);
self.play_uri(&uri, "")
}
fn play_next(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.advance()? {
return Err(ControlPointError::QueueError("No next track".into()));
}
}
self.play_from_queue()
}
fn play_previous(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.rewind()? {
return Err(ControlPointError::QueueError("No previous track".into()));
}
}
self.play_from_queue()
}
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
queue.set_index(Some(index))?;
}
self.play_from_queue()
}
}
impl QueueBackend for LinkPlayRenderer {
fn len(&self) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().len()
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
self.queue.lock().unwrap().track_ids()
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().id_to_position(id)
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
self.queue.lock().unwrap().position_to_id(id)
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
self.queue.lock().unwrap().current_track()
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
self.queue.lock().unwrap().current_index()
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
self.queue.lock().unwrap().queue_snapshot()
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().set_index(index)
}
fn replace_queue(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.replace_queue(items, current_index)
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().sync_queue(items)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
self.queue.lock().unwrap().get_item(index)
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().replace_item(index, item)
}
fn enqueue_items(
&mut self,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().enqueue_items(items, mode)
}
}

View File

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

View File

@@ -14,11 +14,13 @@ use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol
use crate::music_renderer::RendererFromMediaRendererInfo; use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer; use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
use crate::music_renderer::capabilities::{ use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
}; };
use crate::music_renderer::chromecast_renderer::ChromecastRenderer; use crate::music_renderer::chromecast_renderer::ChromecastRenderer;
use crate::music_renderer::linkplay_renderer::LinkPlayRenderer; use crate::music_renderer::linkplay_renderer::LinkPlayRenderer;
use crate::music_renderer::openhome_renderer::OpenHomeRenderer; use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
use crate::music_renderer::sleep_timer::SleepTimer;
use crate::music_renderer::upnp_renderer::UpnpRenderer; use crate::music_renderer::upnp_renderer::UpnpRenderer;
use crate::online::DeviceConnectionState; use crate::online::DeviceConnectionState;
use crate::queue::{ use crate::queue::{
@@ -77,6 +79,8 @@ struct MusicRendererState {
playback_source: PlaybackSource, playback_source: PlaybackSource,
/// Flag to distinguish user-requested stop from automatic events. /// Flag to distinguish user-requested stop from automatic events.
user_stop_requested: bool, user_stop_requested: bool,
/// Sleep timer for auto-stop functionality.
sleep_timer: SleepTimer,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -84,7 +88,6 @@ pub struct MusicRenderer {
info: RendererInfo, info: RendererInfo,
connection: Arc<Mutex<DeviceConnectionState>>, connection: Arc<Mutex<DeviceConnectionState>>,
backend: Arc<Mutex<MusicRendererBackend>>, backend: Arc<Mutex<MusicRendererBackend>>,
queue: Arc<Mutex<MusicQueue>>,
playlist_binding: Arc<Mutex<Option<PlaylistBinding>>>, playlist_binding: Arc<Mutex<Option<PlaylistBinding>>>,
state: Arc<Mutex<MusicRendererState>>, state: Arc<Mutex<MusicRendererState>>,
} }
@@ -93,7 +96,6 @@ impl MusicRenderer {
pub fn new( pub fn new(
info: RendererInfo, info: RendererInfo,
backend: Arc<Mutex<MusicRendererBackend>>, backend: Arc<Mutex<MusicRendererBackend>>,
queue: Arc<Mutex<MusicQueue>>,
) -> Arc<MusicRenderer> { ) -> Arc<MusicRenderer> {
let connection = DeviceConnectionState::new(); let connection = DeviceConnectionState::new();
@@ -101,7 +103,6 @@ impl MusicRenderer {
info, info,
connection: Arc::new(Mutex::new(connection)), connection: Arc::new(Mutex::new(connection)),
backend, backend,
queue,
playlist_binding: Arc::new(Mutex::new(None)), playlist_binding: Arc::new(Mutex::new(None)),
state: Arc::new(Mutex::new(MusicRendererState::default())), state: Arc::new(Mutex::new(MusicRendererState::default())),
}; };
@@ -112,13 +113,11 @@ impl MusicRenderer {
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicRenderer, ControlPointError> { pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicRenderer, ControlPointError> {
let connection = Arc::new(Mutex::new(DeviceConnectionState::new())); let connection = Arc::new(Mutex::new(DeviceConnectionState::new()));
let backend = MusicRendererBackend::make_from_renderer_info(info)?; let backend = MusicRendererBackend::make_from_renderer_info(info)?;
let queue = MusicQueue::make_from_renderer_info(info)?;
let renderer = MusicRenderer { let renderer = MusicRenderer {
info: info.clone(), info: info.clone(),
connection, connection,
backend, backend,
queue,
playlist_binding: Arc::new(Mutex::new(None)), playlist_binding: Arc::new(Mutex::new(None)),
state: Arc::new(Mutex::new(MusicRendererState::default())), state: Arc::new(Mutex::new(MusicRendererState::default())),
}; };
@@ -184,9 +183,9 @@ impl MusicRenderer {
/// Prepare the renderer for attaching a new playlist by clearing the queue and stopping playback. /// Prepare the renderer for attaching a new playlist by clearing the queue and stopping playback.
pub fn clear_for_playlist_attach(&self) -> Result<(), ControlPointError> { pub fn clear_for_playlist_attach(&self) -> Result<(), ControlPointError> {
// Clear the queue first // Clear the queue first
self.queue self.backend
.lock() .lock()
.expect("Queue mutex poisoned") .expect("Backend mutex poisoned")
.clear_queue()?; .clear_queue()?;
// Then stop playback (ignore errors if already stopped) // Then stop playback (ignore errors if already stopped)
@@ -207,9 +206,9 @@ impl MusicRenderer {
/// Get the current queue snapshot. /// Get the current queue snapshot.
pub fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> { pub fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
let mut snapshot = self let mut snapshot = self
.queue .backend
.lock() .lock()
.expect("Queue mutex poisoned") .expect("Backend mutex poisoned")
.queue_snapshot()?; .queue_snapshot()?;
// Enrich snapshot with playlist_id from binding if available // Enrich snapshot with playlist_id from binding if available
@@ -229,72 +228,42 @@ impl MusicRenderer {
/// Get the current queue item without advancing. /// Get the current queue item without advancing.
/// Returns the item and count of remaining items after current. /// Returns the item and count of remaining items after current.
pub fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> { pub fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
self.queue self.backend
.lock() .lock()
.expect("Queue mutex poisoned") .expect("Backend mutex poisoned")
.peek_current() .peek_current()
} }
/// Get the count of items remaining after the current index. /// Get the count of items remaining after the current index.
pub fn upcoming_len(&self) -> Result<usize, ControlPointError> { pub fn upcoming_len(&self) -> Result<usize, ControlPointError> {
self.queue self.backend
.lock() .lock()
.expect("Queue mutex poisoned") .expect("Backend mutex poisoned")
.upcoming_len() .upcoming_len()
} }
/// Play the current item from the queue. /// Play the current item from the queue.
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> { pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().expect("Queue mutex poisoned"); self.backend
let backend = self.backend.lock().expect("Backend mutex poisoned"); .lock()
.expect("Backend mutex poisoned")
// Get the current item from the queue .play_from_queue()
let (item, _remaining) = queue.peek_current()?.ok_or_else(|| {
ControlPointError::QueueError("Queue is empty, cannot play current".to_string())
})?;
// Build DIDL metadata if available
let metadata_xml = item
.metadata
.as_ref()
.map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info))
.unwrap_or_default();
// Play the URI using the backend
backend.play_uri(&item.uri, &metadata_xml)?;
Ok(())
} }
/// Advance to and play the next item from the queue. /// Advance to and play the next item from the queue.
pub fn play_next_from_queue(&self) -> Result<(), ControlPointError> { pub fn play_next_from_queue(&self) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
let backend = self.backend.lock().expect("Backend mutex poisoned");
// Dequeue the next item
let (item, _remaining) = queue
.dequeue_next()?
.ok_or_else(|| ControlPointError::QueueError("No next item in queue".to_string()))?;
// Build DIDL metadata if available
let metadata_xml = item
.metadata
.as_ref()
.map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info))
.unwrap_or_default();
// Play the URI using the backend
backend.play_uri(&item.uri, &metadata_xml)?;
Ok(())
}
/// Transport control: play URI with metadata
pub fn play_uri(&self, uri: &str, metadata: &str) -> Result<(), ControlPointError> {
self.backend self.backend
.lock() .lock()
.expect("Backend mutex poisoned") .expect("Backend mutex poisoned")
.play_uri(uri, metadata) .play_next()
}
/// Play from a specific index in the queue.
pub fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
self.backend
.lock()
.expect("Backend mutex poisoned")
.play_from_index(index)
} }
/// Transport control: play /// Transport control: play
@@ -303,18 +272,16 @@ impl MusicRenderer {
/// joue le track courant de la queue automatiquement (comportement unifié pour tous les backends). /// joue le track courant de la queue automatiquement (comportement unifié pour tous les backends).
pub fn play(&self) -> Result<(), ControlPointError> { pub fn play(&self) -> Result<(), ControlPointError> {
// Vérifier si on a une queue non vide // Vérifier si on a une queue non vide
let queue = self.queue.lock().expect("Queue mutex poisoned"); let backend = self.backend.lock().expect("Backend mutex poisoned");
let queue_not_empty = queue.len().unwrap_or(0) > 0; let queue_not_empty = backend.len().unwrap_or(0) > 0;
drop(queue); // Libérer le lock avant l'appel au backend
if queue_not_empty { if queue_not_empty {
// Si on a des items dans la queue, jouer le track courant (ou le premier si aucun n'est sélectionné) // Si on a des items dans la queue, jouer le track courant (ou le premier si aucun n'est sélectionné)
// peek_current() initialise automatiquement l'index à 0 si nécessaire
// Cela fonctionne pour tous les backends (UPnP interne, OpenHome, etc.) // Cela fonctionne pour tous les backends (UPnP interne, OpenHome, etc.)
self.play_current_from_queue() backend.play_from_queue()
} else { } else {
// Queue vide : déléguer au backend (reprend la lecture en cours, etc.) // Queue vide : déléguer au backend (reprend la lecture en cours, etc.)
self.backend.lock().expect("Backend mutex poisoned").play() backend.play()
} }
} }
@@ -503,14 +470,20 @@ impl MusicRenderer {
.unwrap_or(false) .unwrap_or(false)
} }
/// Returns the number of items in the queue.
pub fn len(&self) -> Result<usize, ControlPointError> {
let backend = self.backend.lock().expect("Backend mutex poisoned");
backend.len()
}
/// Add items to the queue using the specified enqueue mode. /// Add items to the queue using the specified enqueue mode.
pub fn enqueue_items( pub fn enqueue_items(
&self, &self,
items: Vec<PlaybackItem>, items: Vec<PlaybackItem>,
mode: EnqueueMode, mode: EnqueueMode,
) -> Result<(), ControlPointError> { ) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().expect("Queue mutex poisoned"); let mut backend = self.backend.lock().expect("Backend mutex poisoned");
queue.enqueue_items(items, mode) backend.enqueue_items(items, mode)
} }
/// Synchronize the queue with new items while preserving the current track. /// Synchronize the queue with new items while preserving the current track.
@@ -520,21 +493,33 @@ impl MusicRenderer {
/// - If the current track is NOT in the new items, it's preserved as the first item /// - If the current track is NOT in the new items, it's preserved as the first item
/// - If there's no current track, the queue is simply replaced /// - If there's no current track, the queue is simply replaced
pub fn sync_queue(&self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> { pub fn sync_queue(&self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().expect("Queue mutex poisoned"); let mut backend = self.backend.lock().expect("Backend mutex poisoned");
queue.sync_queue(items) backend.sync_queue(items)
} }
/// Set the current queue index (for advanced use). /// Set the current queue index (for advanced use).
/// Note: This does NOT start playback. Use select_queue_track() to play. /// Note: This does NOT start playback. Use select_queue_track() to play.
pub fn set_queue_index(&self, index: Option<usize>) -> Result<(), ControlPointError> { pub fn set_queue_index(&self, index: Option<usize>) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().expect("Queue mutex poisoned"); let mut backend = self.backend.lock().expect("Backend mutex poisoned");
queue.set_index(index) backend.set_index(index)
} }
/// Clears the renderer's queue using the generic QueueBackend trait. /// Clears the renderer's queue using the generic QueueBackend trait.
pub fn clear_queue(&self) -> Result<(), ControlPointError> { pub fn clear_queue(&self) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().expect("Queue mutex poisoned"); let mut backend = self.backend.lock().expect("Backend mutex poisoned");
queue.clear_queue() backend.clear_queue()
}
/// Dequeues and returns the next item from the queue.
pub fn dequeue_next(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
let mut backend = self.backend.lock().expect("Backend mutex poisoned");
backend.dequeue_next()
}
/// Sets the current index in the queue.
pub fn set_index(&self, index: Option<usize>) -> Result<(), ControlPointError> {
let mut backend = self.backend.lock().expect("Backend mutex poisoned");
backend.set_index(index)
} }
/// Replaces the entire queue with new items and sets the current index. /// Replaces the entire queue with new items and sets the current index.
@@ -544,8 +529,8 @@ impl MusicRenderer {
items: Vec<PlaybackItem>, items: Vec<PlaybackItem>,
current_index: Option<usize>, current_index: Option<usize>,
) -> Result<(), ControlPointError> { ) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().expect("Queue mutex poisoned"); let mut backend = self.backend.lock().expect("Backend mutex poisoned");
queue.replace_queue(items, current_index) backend.replace_queue(items, current_index)
} }
/// Adds a track to the queue. /// Adds a track to the queue.
@@ -575,34 +560,14 @@ impl MusicRenderer {
/// Converts the track ID to a position using the generic QueueBackend trait, /// Converts the track ID to a position using the generic QueueBackend trait,
/// then plays the track. /// then plays the track.
pub fn select_queue_track(&self, track_id: u32) -> Result<(), ControlPointError> { pub fn select_queue_track(&self, track_id: u32) -> Result<(), ControlPointError> {
let queue = self.queue.lock().expect("Queue mutex poisoned"); // Convert track_id to index
let index = {
let backend = self.backend.lock().expect("Backend mutex poisoned");
backend.id_to_position(track_id)?
};
// Convert track ID to position using generic QueueBackend trait // Play from that index
let position = queue.id_to_position(track_id)?; self.play_from_index(index)
drop(queue);
// Set the index
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
queue.set_index(Some(position))?;
// Get the item to play
let item = queue
.get_item(position)?
.ok_or_else(|| ControlPointError::QueueError("Track not found".to_string()))?;
drop(queue);
// Build metadata XML
let metadata_xml = item
.metadata
.as_ref()
.map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info))
.unwrap_or_default();
// Play the item using TransportControl
let backend = self.backend.lock().expect("Backend mutex poisoned");
backend.play_uri(&item.uri, &metadata_xml)?;
Ok(())
} }
/// Synchronizes the queue state with the backend. /// Synchronizes the queue state with the backend.
@@ -610,9 +575,9 @@ impl MusicRenderer {
/// For backends with persistent queues (OpenHome), this refreshes the local view. /// For backends with persistent queues (OpenHome), this refreshes the local view.
/// For others, this is essentially a no-op (just reads the current state). /// For others, this is essentially a no-op (just reads the current state).
pub fn sync_queue_state(&self) -> Result<(), ControlPointError> { pub fn sync_queue_state(&self) -> Result<(), ControlPointError> {
let queue = self.queue.lock().expect("Queue mutex poisoned"); let backend = self.backend.lock().expect("Backend mutex poisoned");
// Calling queue_snapshot() triggers a refresh for backends that need it // Calling queue_snapshot() triggers a refresh for backends that need it
let _ = queue.queue_snapshot()?; let _ = backend.queue_snapshot()?;
Ok(()) Ok(())
} }
@@ -620,29 +585,25 @@ impl MusicRenderer {
/// ///
/// This is primarily for backends with persistent queues (OpenHome). /// This is primarily for backends with persistent queues (OpenHome).
pub fn play_current_from_backend_queue(&self) -> Result<(), ControlPointError> { pub fn play_current_from_backend_queue(&self) -> Result<(), ControlPointError> {
let queue = self.queue.lock().expect("Queue mutex poisoned"); let backend = self.backend.lock().expect("Backend mutex poisoned");
// Get current track ID using generic QueueBackend trait // Get current track ID using generic QueueBackend trait
let track_id = queue let track_id = backend
.current_track()? .current_track()?
.ok_or_else(|| ControlPointError::QueueError("No current track".to_string()))?; .ok_or_else(|| ControlPointError::QueueError("No current track".to_string()))?;
drop(queue); drop(backend);
// Play it using select_queue_track // Play it using select_queue_track
self.select_queue_track(track_id) self.select_queue_track(track_id)
} }
/// Returns a reference to the queue (read-only access via lock). /// Plays from the queue at the current position.
/// This is a convenience method to avoid repetitive `queue.lock().unwrap()` patterns. ///
pub fn get_queue(&self) -> std::sync::MutexGuard<'_, MusicQueue> { /// Uses the backend's play_from_queue which preserves the queue for all backends.
self.queue.lock().unwrap() pub fn play_from_queue(&self) -> Result<(), ControlPointError> {
} let backend = self.backend.lock().expect("Backend mutex poisoned");
backend.play_from_queue()
/// Returns a mutable reference to the queue (write access via lock).
/// This is a convenience method to avoid repetitive `queue.lock().unwrap()` patterns.
pub fn get_queue_mut(&self) -> std::sync::MutexGuard<'_, MusicQueue> {
self.queue.lock().unwrap()
} }
// --- Playback State Management --- // --- Playback State Management ---
@@ -695,10 +656,84 @@ impl MusicRenderer {
state.user_stop_requested = false; state.user_stop_requested = false;
was_requested 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 /// Helper function to build DIDL-Lite metadata XML from TrackMetadata
fn build_didl_lite_metadata(metadata: &TrackMetadata, uri: &str, protocol_info: &str) -> String { pub(crate) fn build_didl_lite_metadata(
metadata: &TrackMetadata,
uri: &str,
protocol_info: &str,
) -> String {
format!( format!(
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"> r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
<item id="0" parentID="-1" restricted="1"> <item id="0" parentID="-1" restricted="1">
@@ -1014,3 +1049,217 @@ impl PlaybackPosition for MusicRendererBackend {
} }
} }
} }
impl RendererBackend for MusicRendererBackend {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
match self {
MusicRendererBackend::Upnp(r) => r.queue(),
MusicRendererBackend::OpenHome(r) => r.queue(),
MusicRendererBackend::LinkPlay(r) => r.queue(),
MusicRendererBackend::ArylicTcp(r) => r.queue(),
MusicRendererBackend::Chromecast(cc) => cc.queue(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.queue(),
}
}
}
impl QueueTransportControl for MusicRendererBackend {
fn play_from_queue(&self) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.play_from_queue(),
MusicRendererBackend::OpenHome(r) => r.play_from_queue(),
MusicRendererBackend::LinkPlay(r) => r.play_from_queue(),
MusicRendererBackend::ArylicTcp(r) => r.play_from_queue(),
MusicRendererBackend::Chromecast(cc) => cc.play_from_queue(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_from_queue(),
}
}
fn play_next(&self) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.play_next(),
MusicRendererBackend::OpenHome(r) => r.play_next(),
MusicRendererBackend::LinkPlay(r) => r.play_next(),
MusicRendererBackend::ArylicTcp(r) => r.play_next(),
MusicRendererBackend::Chromecast(cc) => cc.play_next(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_next(),
}
}
fn play_previous(&self) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.play_previous(),
MusicRendererBackend::OpenHome(r) => r.play_previous(),
MusicRendererBackend::LinkPlay(r) => r.play_previous(),
MusicRendererBackend::ArylicTcp(r) => r.play_previous(),
MusicRendererBackend::Chromecast(cc) => cc.play_previous(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_previous(),
}
}
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.play_from_index(index),
MusicRendererBackend::OpenHome(r) => r.play_from_index(index),
MusicRendererBackend::LinkPlay(r) => r.play_from_index(index),
MusicRendererBackend::ArylicTcp(r) => r.play_from_index(index),
MusicRendererBackend::Chromecast(cc) => cc.play_from_index(index),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_from_index(index),
}
}
}
impl QueueBackend for MusicRendererBackend {
fn len(&self) -> Result<usize, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.len(),
MusicRendererBackend::OpenHome(r) => r.len(),
MusicRendererBackend::LinkPlay(r) => r.len(),
MusicRendererBackend::ArylicTcp(r) => r.len(),
MusicRendererBackend::Chromecast(cc) => cc.len(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.len(),
}
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.track_ids(),
MusicRendererBackend::OpenHome(r) => r.track_ids(),
MusicRendererBackend::LinkPlay(r) => r.track_ids(),
MusicRendererBackend::ArylicTcp(r) => r.track_ids(),
MusicRendererBackend::Chromecast(cc) => cc.track_ids(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.track_ids(),
}
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.id_to_position(id),
MusicRendererBackend::OpenHome(r) => r.id_to_position(id),
MusicRendererBackend::LinkPlay(r) => r.id_to_position(id),
MusicRendererBackend::ArylicTcp(r) => r.id_to_position(id),
MusicRendererBackend::Chromecast(cc) => cc.id_to_position(id),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.id_to_position(id),
}
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.position_to_id(id),
MusicRendererBackend::OpenHome(r) => r.position_to_id(id),
MusicRendererBackend::LinkPlay(r) => r.position_to_id(id),
MusicRendererBackend::ArylicTcp(r) => r.position_to_id(id),
MusicRendererBackend::Chromecast(cc) => cc.position_to_id(id),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.position_to_id(id),
}
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.current_track(),
MusicRendererBackend::OpenHome(r) => r.current_track(),
MusicRendererBackend::LinkPlay(r) => r.current_track(),
MusicRendererBackend::ArylicTcp(r) => r.current_track(),
MusicRendererBackend::Chromecast(cc) => cc.current_track(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.current_track(),
}
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.current_index(),
MusicRendererBackend::OpenHome(r) => r.current_index(),
MusicRendererBackend::LinkPlay(r) => r.current_index(),
MusicRendererBackend::ArylicTcp(r) => r.current_index(),
MusicRendererBackend::Chromecast(cc) => cc.current_index(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.current_index(),
}
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.queue_snapshot(),
MusicRendererBackend::OpenHome(r) => r.queue_snapshot(),
MusicRendererBackend::LinkPlay(r) => r.queue_snapshot(),
MusicRendererBackend::ArylicTcp(r) => r.queue_snapshot(),
MusicRendererBackend::Chromecast(cc) => cc.queue_snapshot(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.queue_snapshot(),
}
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.set_index(index),
MusicRendererBackend::OpenHome(r) => r.set_index(index),
MusicRendererBackend::LinkPlay(r) => r.set_index(index),
MusicRendererBackend::ArylicTcp(r) => r.set_index(index),
MusicRendererBackend::Chromecast(cc) => cc.set_index(index),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_index(index),
}
}
fn replace_queue(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.replace_queue(items, current_index),
MusicRendererBackend::OpenHome(r) => r.replace_queue(items, current_index),
MusicRendererBackend::LinkPlay(r) => r.replace_queue(items, current_index),
MusicRendererBackend::ArylicTcp(r) => r.replace_queue(items, current_index),
MusicRendererBackend::Chromecast(cc) => cc.replace_queue(items, current_index),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => {
upnp.replace_queue(items, current_index)
}
}
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.sync_queue(items),
MusicRendererBackend::OpenHome(r) => r.sync_queue(items),
MusicRendererBackend::LinkPlay(r) => r.sync_queue(items),
MusicRendererBackend::ArylicTcp(r) => r.sync_queue(items),
MusicRendererBackend::Chromecast(cc) => cc.sync_queue(items),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.sync_queue(items),
}
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.get_item(index),
MusicRendererBackend::OpenHome(r) => r.get_item(index),
MusicRendererBackend::LinkPlay(r) => r.get_item(index),
MusicRendererBackend::ArylicTcp(r) => r.get_item(index),
MusicRendererBackend::Chromecast(cc) => cc.get_item(index),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.get_item(index),
}
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.replace_item(index, item),
MusicRendererBackend::OpenHome(r) => r.replace_item(index, item),
MusicRendererBackend::LinkPlay(r) => r.replace_item(index, item),
MusicRendererBackend::ArylicTcp(r) => r.replace_item(index, item),
MusicRendererBackend::Chromecast(cc) => cc.replace_item(index, item),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.replace_item(index, item),
}
}
fn enqueue_items(
&mut self,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.enqueue_items(items, mode),
MusicRendererBackend::OpenHome(r) => r.enqueue_items(items, mode),
MusicRendererBackend::LinkPlay(r) => r.enqueue_items(items, mode),
MusicRendererBackend::ArylicTcp(r) => r.enqueue_items(items, mode),
MusicRendererBackend::Chromecast(cc) => cc.enqueue_items(items, mode),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.enqueue_items(items, mode),
}
}
}

View File

@@ -1,6 +1,9 @@
use std::sync::{Arc, Mutex};
use crate::DeviceIdentity; use crate::DeviceIdentity;
use crate::music_renderer::capabilities::{ use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
}; };
use crate::music_renderer::time_utils::{format_hhmmss_u32, parse_time_flexible}; use crate::music_renderer::time_utils::{format_hhmmss_u32, parse_time_flexible};
@@ -12,6 +15,7 @@ use crate::music_renderer::openhome::{
build_info_client, build_playlist_client, build_product_client, build_radio_client, build_info_client, build_playlist_client, build_product_client, build_radio_client,
build_time_client, build_volume_client, build_time_client, build_volume_client,
}; };
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::upnp_clients::{ use crate::upnp_clients::{
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
OhTimeClient, OhVolumeClient, OhTimeClient, OhVolumeClient,
@@ -27,6 +31,7 @@ pub struct OpenHomeRenderer {
product_client: Option<OhProductClient>, product_client: Option<OhProductClient>,
#[allow(dead_code)] #[allow(dead_code)]
radio_client: Option<OhRadioClient>, radio_client: Option<OhRadioClient>,
queue: Arc<Mutex<MusicQueue>>,
} }
impl OpenHomeRenderer { impl OpenHomeRenderer {
@@ -37,6 +42,7 @@ impl OpenHomeRenderer {
volume_client: Option<OhVolumeClient>, volume_client: Option<OhVolumeClient>,
product_client: Option<OhProductClient>, product_client: Option<OhProductClient>,
radio_client: Option<OhRadioClient>, radio_client: Option<OhRadioClient>,
queue: Arc<Mutex<MusicQueue>>,
) -> Self { ) -> Self {
Self { Self {
playlist, playlist,
@@ -45,6 +51,7 @@ impl OpenHomeRenderer {
volume_client, volume_client,
product_client, product_client,
radio_client, radio_client,
queue,
} }
} }
@@ -180,6 +187,9 @@ impl OpenHomeRenderer {
impl RendererFromMediaRendererInfo for OpenHomeRenderer { impl RendererFromMediaRendererInfo for OpenHomeRenderer {
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> { fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
// Create the OpenHome queue
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
let renderer = OpenHomeRenderer::new( let renderer = OpenHomeRenderer::new(
build_playlist_client(&info), build_playlist_client(&info),
build_info_client(&info), build_info_client(&info),
@@ -187,6 +197,7 @@ impl RendererFromMediaRendererInfo for OpenHomeRenderer {
build_volume_client(&info), build_volume_client(&info),
build_product_client(&info), build_product_client(&info),
build_radio_client(&info), build_radio_client(&info),
queue,
); );
if renderer.has_any_openhome_service() { if renderer.has_any_openhome_service() {
@@ -204,6 +215,12 @@ impl RendererFromMediaRendererInfo for OpenHomeRenderer {
} }
} }
impl RendererBackend for OpenHomeRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
&self.queue
}
}
impl TransportControl for OpenHomeRenderer { impl TransportControl for OpenHomeRenderer {
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
let playlist = self.playlist_client_for("play_uri")?; let playlist = self.playlist_client_for("play_uri")?;
@@ -330,3 +347,133 @@ pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
other => PlaybackState::Unknown(other.to_string()), other => PlaybackState::Unknown(other.to_string()),
} }
} }
impl QueueTransportControl for OpenHomeRenderer {
fn play_from_queue(&self) -> Result<(), ControlPointError> {
{
let queue = self.queue.lock().unwrap();
if queue.current_index()?.is_none() {
if queue.len()? > 0 {
drop(queue);
let mut queue = self.queue.lock().unwrap();
queue.set_index(Some(0))?;
} else {
return Err(ControlPointError::QueueError("Queue is empty".into()));
}
}
}
let playlist = self.playlist_client_for("play_from_queue")?;
playlist.play()
}
fn play_next(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.advance()? {
return Err(ControlPointError::QueueError("No next track".into()));
}
}
self.play_from_queue()
}
fn play_previous(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.rewind()? {
return Err(ControlPointError::QueueError("No previous track".into()));
}
}
self.play_from_queue()
}
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
// For OpenHome, we need to convert index to track_id
let track_id = {
let queue = self.queue.lock().unwrap();
queue.position_to_id(index)?
};
// Seek to the track by ID
let playlist = self.playlist_client_for("play_from_index")?;
playlist.seek_id(track_id)?;
// Update local queue index
{
let mut queue = self.queue.lock().unwrap();
queue.set_index(Some(index))?;
}
// Start playback
playlist.play()?;
Ok(())
}
}
impl QueueBackend for OpenHomeRenderer {
fn len(&self) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().len()
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
self.queue.lock().unwrap().track_ids()
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().id_to_position(id)
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
self.queue.lock().unwrap().position_to_id(id)
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
self.queue.lock().unwrap().current_track()
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
self.queue.lock().unwrap().current_index()
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
self.queue.lock().unwrap().queue_snapshot()
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().set_index(index)
}
fn replace_queue(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.replace_queue(items, current_index)
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().sync_queue(items)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
self.queue.lock().unwrap().get_item(index)
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().replace_item(index, item)
}
fn enqueue_items(
&mut self,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().enqueue_items(items, mode)
}
}

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

@@ -1,11 +1,14 @@
use std::sync::{Arc, Mutex};
use crate::errors::ControlPointError; use crate::errors::ControlPointError;
use crate::model::PlaybackState;
use crate::music_renderer::RendererFromMediaRendererInfo; use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::music_renderer::capabilities::{ use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
VolumeControl, TransportControl, VolumeControl,
}; };
use crate::model::PlaybackState; use crate::music_renderer::musicrenderer::{MusicRendererBackend, build_didl_lite_metadata};
use crate::music_renderer::musicrenderer::MusicRendererBackend; use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::upnp_clients::{ use crate::upnp_clients::{
AvTransportClient, ConnectionInfo, ConnectionManagerClient, PositionInfo, ProtocolInfo, AvTransportClient, ConnectionInfo, ConnectionManagerClient, PositionInfo, ProtocolInfo,
RenderingControlClient, RenderingControlClient,
@@ -19,6 +22,7 @@ pub struct UpnpRenderer {
rendering_control: Option<RenderingControlClient>, rendering_control: Option<RenderingControlClient>,
connection_manager: Option<ConnectionManagerClient>, connection_manager: Option<ConnectionManagerClient>,
has_avtransport_set_next: bool, has_avtransport_set_next: bool,
queue: Arc<Mutex<MusicQueue>>,
} }
impl UpnpRenderer { impl UpnpRenderer {
@@ -85,6 +89,24 @@ impl UpnpRenderer {
} }
} }
impl UpnpRenderer {
pub fn new(
avtransport: Option<AvTransportClient>,
rendering_control: Option<RenderingControlClient>,
connection_manager: Option<ConnectionManagerClient>,
has_avtransport_set_next: bool,
queue: Arc<Mutex<MusicQueue>>,
) -> Self {
Self {
avtransport,
rendering_control,
connection_manager,
has_avtransport_set_next,
queue,
}
}
}
impl RendererFromMediaRendererInfo for UpnpRenderer { impl RendererFromMediaRendererInfo for UpnpRenderer {
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> { fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
// Prepare le service AVTTransport // Prepare le service AVTTransport
@@ -122,11 +144,15 @@ impl RendererFromMediaRendererInfo for UpnpRenderer {
))); )));
} }
// Create the internal queue
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
Ok(Self { Ok(Self {
avtransport, avtransport,
rendering_control, rendering_control,
connection_manager, connection_manager,
has_avtransport_set_next: info.capabilities().has_avtransport_set_next(), has_avtransport_set_next: info.capabilities().has_avtransport_set_next(),
queue,
}) })
} }
@@ -135,6 +161,152 @@ impl RendererFromMediaRendererInfo for UpnpRenderer {
} }
} }
impl RendererBackend for UpnpRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
&self.queue
}
}
impl QueueTransportControl for UpnpRenderer {
fn play_from_queue(&self) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().unwrap();
// Get or initialize current index
let current_index = match queue.current_index()? {
Some(idx) => idx,
None => {
if queue.len()? > 0 {
queue.set_index(Some(0))?;
0
} else {
return Err(ControlPointError::QueueError("Queue is empty".into()));
}
}
};
// Get the item
let item = queue
.get_item(current_index)?
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
drop(queue);
// Build metadata - handle optional TrackMetadata
let metadata = if let Some(ref track_metadata) = item.metadata {
build_didl_lite_metadata(track_metadata, &item.uri, &item.protocol_info)
} else {
// Fallback to minimal DIDL-Lite if no metadata
format!(
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="0" parentID="-1" restricted="1"><res protocolInfo="{}">{}</res></item></DIDL-Lite>"#,
item.protocol_info, item.uri
)
};
// UPNP: SetAVTransportURI + Play
let avt = self.avtransport()?;
avt.set_av_transport_uri(&item.uri, &metadata)?;
avt.play(0, "1")?;
Ok(())
}
fn play_next(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.advance()? {
return Err(ControlPointError::QueueError("No next track".into()));
}
}
self.play_from_queue()
}
fn play_previous(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.rewind()? {
return Err(ControlPointError::QueueError("No previous track".into()));
}
}
self.play_from_queue()
}
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
queue.set_index(Some(index))?;
}
self.play_from_queue()
}
}
impl QueueBackend for UpnpRenderer {
fn len(&self) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().len()
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
self.queue.lock().unwrap().track_ids()
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().id_to_position(id)
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
self.queue.lock().unwrap().position_to_id(id)
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
self.queue.lock().unwrap().current_track()
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
self.queue.lock().unwrap().current_index()
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
self.queue.lock().unwrap().queue_snapshot()
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().set_index(index)
}
fn replace_queue(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.replace_queue(items, current_index)
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().sync_queue(items)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
self.queue.lock().unwrap().get_item(index)
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().replace_item(index, item)
}
fn enqueue_items(
&mut self,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().enqueue_items(items, mode)
}
}
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`]. /// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
/// ///
/// Cette impl se base sur AVTransport (InstanceID = 0). /// Cette impl se base sur AVTransport (InstanceID = 0).

View File

@@ -282,6 +282,26 @@ pub struct TransferQueueRequest {
pub destination_renderer_id: String, 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 /// Réponse générique de succès
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Serialize, ToSchema)] #[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_up_renderer,
crate::pmoserver_ext::volume_down_renderer, crate::pmoserver_ext::volume_down_renderer,
crate::pmoserver_ext::toggle_mute_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::attach_playlist_binding,
crate::pmoserver_ext::detach_playlist_binding, crate::pmoserver_ext::detach_playlist_binding,
crate::pmoserver_ext::play_content, crate::pmoserver_ext::play_content,
@@ -456,6 +480,8 @@ GET /control/servers/{server_id}/containers/{container_id}
SeekQueueRequest, SeekQueueRequest,
SeekRequest, SeekRequest,
TransferQueueRequest, TransferQueueRequest,
SleepTimerRequest,
SleepTimerState,
SuccessResponse, SuccessResponse,
ErrorResponse, ErrorResponse,
)), )),

View File

@@ -14,7 +14,8 @@ use crate::openapi::{
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse, AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot, FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot,
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary, RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
SeekQueueRequest, SeekRequest, SuccessResponse, TransferQueueRequest, VolumeSetRequest, SeekQueueRequest, SeekRequest, SleepTimerRequest, SleepTimerState, SuccessResponse,
TransferQueueRequest, VolumeSetRequest,
}; };
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
use crate::queue::PlaybackItem; 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 // HANDLERS - BINDING PLAYLIST
// ============================================================================ // ============================================================================
@@ -1987,6 +2197,20 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
"/renderers/{renderer_id}/mute/toggle", "/renderers/{renderer_id}/mute/toggle",
post(toggle_mute_renderer), 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 // Playlist binding
.route( .route(
"/renderers/{renderer_id}/binding/attach", "/renderers/{renderer_id}/binding/attach",

View File

@@ -85,6 +85,31 @@ pub enum RendererEventPayload {
container_id: Option<String>, container_id: Option<String>,
timestamp: chrono::DateTime<chrono::Utc>, 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 { Online {
renderer_id: String, renderer_id: String,
friendly_name: String, friendly_name: String,
@@ -273,6 +298,41 @@ pub async fn renderer_events_sse(
timestamp, 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 } => { RendererEvent::Online { id, info } => {
RendererEventPayload::Online { RendererEventPayload::Online {
renderer_id: id.0, renderer_id: id.0,
@@ -647,6 +707,41 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
timestamp, 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 } => { RendererEvent::Online { id, info } => {
RendererEventPayload::Online { RendererEventPayload::Online {
renderer_id: id.0, renderer_id: id.0,

View File

@@ -276,7 +276,8 @@ impl OhPlaylistClient {
let envelope = ensure_success("Id", &call_result)?; let envelope = ensure_success("Id", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "IdResponse").ok_or_else(|| { let response =
find_child_with_suffix(&envelope.body.content, "IdResponse").ok_or_else(|| {
ControlPointError::OpenHomeError(format!( ControlPointError::OpenHomeError(format!(
"Missing ReadResponse element in SOAP body" "Missing ReadResponse element in SOAP body"
)) ))
@@ -316,7 +317,7 @@ impl OhPlaylistClient {
pub fn seek_second_absolute(&self, second: u32) -> Result<(), ControlPointError> { pub fn seek_second_absolute(&self, second: u32) -> Result<(), ControlPointError> {
let second_str = second.to_string(); let second_str = second.to_string();
let args = [("Second", second_str.as_str())]; let args = [("Value", second_str.as_str())];
let call_result = invoke_upnp_action( let call_result = invoke_upnp_action(
&self.control_url, &self.control_url,
&self.service_type, &self.service_type,