- Update Cargo.toml and version.txt to v0.3.46 - Fix production bug in music renderer autoadvance logic: add fallback timeout (20s) to compensate for missed PLAYING events due DB latency - Update Cargo.lock accordingly
2497 lines
99 KiB
Rust
2497 lines
99 KiB
Rust
//! Backend-agnostic music renderer façade for PMOMusic.
|
|
//!
|
|
//! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, OpenHome,
|
|
//! LinkPlay HTTP, Arylic TCP, Chromecast, and the hybrid UPnP + Arylic pairing) behind a
|
|
//! single control surface. Higher layers in PMOMusic must only interact with
|
|
//! renderers through this type so that transport, volume, and state queries
|
|
//! stay backend-neutral.
|
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::thread::{self, JoinHandle};
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
use pmodidl::{DIDLLite, MediaMetadataParser};
|
|
use tracing::{debug, error};
|
|
|
|
use crate::errors::ControlPointError;
|
|
use crate::events::RendererEventBus;
|
|
use crate::model::RendererEvent;
|
|
use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol, TrackMetadata};
|
|
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
|
|
use crate::music_renderer::capabilities::{
|
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
|
TransportControl, VolumeControl,
|
|
};
|
|
use crate::music_renderer::chromecast_renderer::ChromecastRenderer;
|
|
use crate::music_renderer::linkplay_renderer::LinkPlayRenderer;
|
|
use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
|
|
use crate::music_renderer::sleep_timer::SleepTimer;
|
|
use crate::music_renderer::upnp_renderer::UpnpRenderer;
|
|
use crate::music_renderer::watcher::{
|
|
extract_track_metadata, playback_position_equal, playback_state_equal, WatchStrategy,
|
|
WatchedState,
|
|
};
|
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
|
use crate::online::DeviceConnectionState;
|
|
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
|
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
|
|
|
|
use tracing::warn;
|
|
|
|
/// Describes a renderer's attachment to a media server playlist container.
|
|
///
|
|
/// When a renderer is attached to a playlist, the ControlPoint monitors the container
|
|
/// for updates and automatically refreshes the queue when changes are detected.
|
|
#[derive(Clone, Debug)]
|
|
pub struct PlaylistBinding {
|
|
/// MediaServer that owns the playlist container.
|
|
pub server_id: DeviceId,
|
|
/// DIDL-Lite object id of the playlist container.
|
|
pub container_id: String,
|
|
/// True once at least one ContainerUpdateIDs notification has been seen.
|
|
pub(crate) has_seen_update: bool,
|
|
/// Flag used internally to signal that the queue should be refreshed
|
|
/// from the server container.
|
|
pub(crate) pending_refresh: bool,
|
|
/// Whether the next refresh should auto-start playback if the renderer is idle.
|
|
pub(crate) auto_play_on_refresh: bool,
|
|
}
|
|
|
|
/// Backend-agnostic façade exposing transport, volume, and status contracts.
|
|
#[derive(Clone, Debug)]
|
|
pub enum MusicRendererBackend {
|
|
/// Classic UPnP AV / DLNA renderer (AVTransport + RenderingControl).
|
|
Upnp(UpnpRenderer),
|
|
/// Renderer powered by OpenHome services.
|
|
OpenHome(OpenHomeRenderer),
|
|
/// Renderer controlled via the LinkPlay HTTP API.
|
|
LinkPlay(LinkPlayRenderer),
|
|
/// Renderer reachable through the Arylic TCP control protocol (port 8899).
|
|
ArylicTcp(ArylicTcpRenderer),
|
|
/// Renderer controlled via the Google Cast protocol (Chromecast).
|
|
Chromecast(ChromecastRenderer),
|
|
/// Combined backend using UPnP for transport + volume writes and Arylic TCP
|
|
/// to read detailed playback information as well as live volume/mute state.
|
|
HybridUpnpArylic {
|
|
upnp: UpnpRenderer,
|
|
arylic: ArylicTcpRenderer,
|
|
},
|
|
}
|
|
|
|
/// Internal state for tracking playback and control flow.
|
|
#[derive(Debug, Clone)]
|
|
struct MusicRendererState {
|
|
/// Last known track metadata (cached to avoid repeated queries).
|
|
last_metadata: Option<TrackMetadata>,
|
|
/// Source of the current playback (queue vs external).
|
|
playback_source: PlaybackSource,
|
|
/// Flag to distinguish user-requested stop from automatic events.
|
|
user_stop_requested: bool,
|
|
/// Sleep timer for auto-stop functionality.
|
|
sleep_timer: SleepTimer,
|
|
/// Flag indicating that a PLAYING state has been observed since the last track start.
|
|
/// This prevents auto-advance on transient STOPPED states during track initialization.
|
|
/// Auto-advance is only allowed when this flag is true.
|
|
has_played_since_track_start: bool,
|
|
/// Timestamp when the current track started playing.
|
|
/// Used to calculate elapsed time when renderer returns unreliable position info.
|
|
track_start_time: Option<SystemTime>,
|
|
/// Current track duration (HH:MM:SS format).
|
|
/// For continuous streams, this is kept stable and only updated when it increases
|
|
/// (to avoid decreasing duration updates from radio metadata).
|
|
current_track_duration: Option<String>,
|
|
}
|
|
|
|
impl Default for MusicRendererState {
|
|
fn default() -> Self {
|
|
Self {
|
|
last_metadata: None,
|
|
playback_source: PlaybackSource::default(),
|
|
user_stop_requested: false,
|
|
sleep_timer: SleepTimer::default(),
|
|
has_played_since_track_start: false,
|
|
track_start_time: None,
|
|
current_track_duration: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct MusicRenderer {
|
|
info: RendererInfo,
|
|
connection: Arc<Mutex<DeviceConnectionState>>,
|
|
backend: Arc<Mutex<MusicRendererBackend>>,
|
|
playlist_binding: Arc<Mutex<Option<PlaylistBinding>>>,
|
|
state: Arc<Mutex<MusicRendererState>>,
|
|
/// Optional event bus for emitting queue change events.
|
|
event_bus: Option<RendererEventBus>,
|
|
/// Cached state from last poll, used for change detection by the watcher.
|
|
watched_state: Arc<Mutex<WatchedState>>,
|
|
/// Flag to signal the watcher thread to stop.
|
|
watcher_stop_flag: Arc<AtomicBool>,
|
|
/// Handle to the watcher thread, if running.
|
|
watcher_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
|
|
}
|
|
|
|
impl std::fmt::Debug for MusicRenderer {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("MusicRenderer")
|
|
.field("info", &self.info)
|
|
.field("connection", &self.connection)
|
|
.field("backend", &self.backend)
|
|
.field("playlist_binding", &self.playlist_binding)
|
|
.field("state", &self.state)
|
|
.field(
|
|
"event_bus",
|
|
&self.event_bus.as_ref().map(|_| "RendererEventBus"),
|
|
)
|
|
.field("is_watching", &self.is_watching())
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl MusicRenderer {
|
|
pub fn new(
|
|
info: RendererInfo,
|
|
backend: Arc<Mutex<MusicRendererBackend>>,
|
|
) -> Arc<MusicRenderer> {
|
|
let connection = DeviceConnectionState::new();
|
|
|
|
let renderer = MusicRenderer {
|
|
info,
|
|
connection: Arc::new(Mutex::new(connection)),
|
|
backend,
|
|
playlist_binding: Arc::new(Mutex::new(None)),
|
|
state: Arc::new(Mutex::new(MusicRendererState::default())),
|
|
event_bus: None,
|
|
watched_state: Arc::new(Mutex::new(WatchedState::default())),
|
|
watcher_stop_flag: Arc::new(AtomicBool::new(false)),
|
|
watcher_handle: Arc::new(Mutex::new(None)),
|
|
};
|
|
|
|
Arc::new(renderer)
|
|
}
|
|
|
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicRenderer, ControlPointError> {
|
|
Self::from_renderer_info_with_bus(info, None)
|
|
}
|
|
|
|
pub fn from_renderer_info_with_bus(
|
|
info: &RendererInfo,
|
|
event_bus: Option<RendererEventBus>,
|
|
) -> Result<MusicRenderer, ControlPointError> {
|
|
let connection = Arc::new(Mutex::new(DeviceConnectionState::new()));
|
|
let backend = MusicRendererBackend::make_from_renderer_info(info)?;
|
|
|
|
let renderer = MusicRenderer {
|
|
info: info.clone(),
|
|
connection,
|
|
backend,
|
|
playlist_binding: Arc::new(Mutex::new(None)),
|
|
state: Arc::new(Mutex::new(MusicRendererState::default())),
|
|
event_bus,
|
|
watched_state: Arc::new(Mutex::new(WatchedState::default())),
|
|
watcher_stop_flag: Arc::new(AtomicBool::new(false)),
|
|
watcher_handle: Arc::new(Mutex::new(None)),
|
|
};
|
|
|
|
// Start watching immediately since the renderer is created online
|
|
renderer.start_watching();
|
|
|
|
Ok(renderer)
|
|
}
|
|
|
|
/// Helper method to emit a QueueUpdated event if an event bus is available.
|
|
fn emit_queue_updated(&self) {
|
|
if let Some(ref bus) = self.event_bus {
|
|
let queue_length = self.len().unwrap_or(0);
|
|
bus.broadcast(RendererEvent::QueueUpdated {
|
|
id: self.id(),
|
|
queue_length,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Helper method to emit any RendererEvent if an event bus is available.
|
|
fn emit_event(&self, event: RendererEvent) {
|
|
if let Some(ref bus) = self.event_bus {
|
|
bus.broadcast(event);
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Watcher Thread Management
|
|
// =========================================================================
|
|
|
|
/// Starts the watcher thread for this renderer.
|
|
///
|
|
/// The watcher thread polls the backend at regular intervals and emits
|
|
/// events when state changes are detected. This method is idempotent:
|
|
/// calling it when already watching is a no-op.
|
|
pub fn start_watching(&self) {
|
|
let mut handle_guard = self
|
|
.watcher_handle
|
|
.lock()
|
|
.expect("Watcher handle mutex poisoned");
|
|
if handle_guard.is_some() {
|
|
return; // Already watching
|
|
}
|
|
|
|
// Reset the stop flag before starting
|
|
self.watcher_stop_flag.store(false, Ordering::SeqCst);
|
|
|
|
// Determine the watch strategy based on backend type
|
|
let strategy = {
|
|
let backend = match self.backend.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => {
|
|
warn!(
|
|
renderer = self.info.friendly_name(),
|
|
"Backend mutex poisoned when starting watcher, recovering"
|
|
);
|
|
poisoned.into_inner()
|
|
}
|
|
};
|
|
WatchStrategy::for_backend(&*backend)
|
|
};
|
|
|
|
debug!(
|
|
renderer = self.info.friendly_name(),
|
|
strategy = ?strategy,
|
|
"Starting watcher thread"
|
|
);
|
|
|
|
let handle = self.spawn_watcher_thread(strategy);
|
|
*handle_guard = Some(handle);
|
|
}
|
|
|
|
/// Stops the watcher thread gracefully.
|
|
///
|
|
/// This method is idempotent: calling it when not watching is a no-op.
|
|
/// The method will block until the watcher thread terminates.
|
|
pub fn stop_watching(&self) {
|
|
// Signal the watcher to stop
|
|
self.watcher_stop_flag.store(true, Ordering::SeqCst);
|
|
|
|
// Take the handle and wait for the thread to finish
|
|
let mut handle_guard = self
|
|
.watcher_handle
|
|
.lock()
|
|
.expect("Watcher handle mutex poisoned");
|
|
if let Some(handle) = handle_guard.take() {
|
|
debug!(
|
|
renderer = self.info.friendly_name(),
|
|
"Stopping watcher thread"
|
|
);
|
|
// Wait for the thread to finish (ignore join errors)
|
|
let _ = handle.join();
|
|
}
|
|
}
|
|
|
|
/// Returns true if the watcher thread is currently running.
|
|
pub fn is_watching(&self) -> bool {
|
|
self.watcher_handle
|
|
.lock()
|
|
.expect("Watcher handle mutex poisoned")
|
|
.is_some()
|
|
}
|
|
|
|
/// Spawns the watcher thread with the given strategy.
|
|
fn spawn_watcher_thread(&self, strategy: WatchStrategy) -> JoinHandle<()> {
|
|
let renderer = self.clone();
|
|
let stop_flag = Arc::clone(&self.watcher_stop_flag);
|
|
|
|
thread::Builder::new()
|
|
.name(format!("watcher-{}", self.info.friendly_name()))
|
|
.spawn(move || {
|
|
renderer.watcher_loop(strategy, stop_flag);
|
|
})
|
|
.expect("Failed to spawn watcher thread")
|
|
}
|
|
|
|
/// Main loop for the watcher thread.
|
|
fn watcher_loop(&self, strategy: WatchStrategy, stop_flag: Arc<AtomicBool>) {
|
|
let Some(base_interval) = strategy.polling_interval() else {
|
|
// Pure push strategy - no polling needed (future implementation)
|
|
return;
|
|
};
|
|
|
|
let short_interval = base_interval;
|
|
let long_interval = Duration::from_millis(5_000);
|
|
let inactivity_threshold = Duration::from_secs(10);
|
|
|
|
let mut tick: u32 = 0;
|
|
let mut next_poll_time = SystemTime::now();
|
|
let mut last_activity_time = SystemTime::now();
|
|
|
|
while !stop_flag.load(Ordering::SeqCst) {
|
|
let is_active = {
|
|
let watched = self
|
|
.watched_state
|
|
.lock()
|
|
.expect("WatchedState mutex poisoned");
|
|
watched.is_active
|
|
};
|
|
|
|
let interval = if is_active {
|
|
short_interval
|
|
} else {
|
|
long_interval
|
|
};
|
|
|
|
if self.is_online() {
|
|
self.poll_and_emit_changes(tick);
|
|
last_activity_time = SystemTime::now();
|
|
}
|
|
|
|
tick = tick.wrapping_add(1);
|
|
|
|
// Calculate next poll time to maintain fixed interval
|
|
next_poll_time += interval;
|
|
|
|
// Sleep until next poll time (or skip if we're already late)
|
|
if let Ok(sleep_duration) = next_poll_time.duration_since(SystemTime::now()) {
|
|
thread::sleep(sleep_duration);
|
|
} else {
|
|
// We're running late - log a warning and reset the schedule
|
|
tracing::warn!(
|
|
renderer = self.info.friendly_name(),
|
|
"Watcher polling is running late, resetting schedule"
|
|
);
|
|
next_poll_time = SystemTime::now();
|
|
}
|
|
|
|
// Reset is_active flag if no activity for threshold
|
|
if let Ok(elapsed) = SystemTime::now().duration_since(last_activity_time) {
|
|
if elapsed >= inactivity_threshold {
|
|
let mut watched = self
|
|
.watched_state
|
|
.lock()
|
|
.expect("WatchedState mutex poisoned");
|
|
watched.is_active = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
debug!(
|
|
renderer = self.info.friendly_name(),
|
|
"Watcher thread exiting"
|
|
);
|
|
}
|
|
|
|
/// Polls the backend and emits events for any detected changes.
|
|
fn poll_and_emit_changes(&self, tick: u32) {
|
|
// Step 1: Read previous state WITHOUT holding the lock during network calls
|
|
let prev_position = {
|
|
let watched = self
|
|
.watched_state
|
|
.lock()
|
|
.expect("WatchedState mutex poisoned");
|
|
watched.position.clone()
|
|
};
|
|
|
|
// Step 2: Do all network calls WITHOUT holding any locks
|
|
// This prevents blocking other threads that need to read watched_state
|
|
let position = self.playback_position().ok();
|
|
let raw_state = self.playback_state().ok();
|
|
|
|
// Poll volume and mute every other tick (1 second at 500ms interval)
|
|
let (volume, mute, is_stream) = if tick % 2 == 0 {
|
|
(
|
|
self.volume().ok(),
|
|
self.mute().ok(),
|
|
Some(self.is_playing_a_stream()),
|
|
)
|
|
} else {
|
|
(None, None, None)
|
|
};
|
|
|
|
// Step 3: Now acquire the lock and update state based on polling results
|
|
let mut watched = self
|
|
.watched_state
|
|
.lock()
|
|
.expect("WatchedState mutex poisoned");
|
|
|
|
// Detect and handle metadata/track changes FIRST
|
|
if let Some(ref pos) = position {
|
|
if let Some(metadata) = extract_track_metadata(pos) {
|
|
let metadata_changed = watched
|
|
.metadata
|
|
.as_ref()
|
|
.map(|prev| prev != &metadata)
|
|
.unwrap_or(true);
|
|
|
|
if metadata_changed {
|
|
// Check if this is a track change (title/artist/album) to reset track_start_time
|
|
// Also initialize track_start_time on first metadata detection
|
|
let is_first_metadata = watched.metadata.is_none();
|
|
let track_changed = watched
|
|
.metadata
|
|
.as_ref()
|
|
.map(|prev| {
|
|
let title_changed = prev.title != metadata.title;
|
|
let artist_changed = prev.artist != metadata.artist;
|
|
let album_changed = prev.album != metadata.album;
|
|
|
|
if !is_first_metadata {
|
|
tracing::info!(
|
|
"MusicRenderer [{}]: Comparing - title_changed={} ({:?} vs {:?}), artist_changed={} ({:?} vs {:?})",
|
|
self.info.friendly_name(),
|
|
title_changed, prev.title, metadata.title,
|
|
artist_changed, prev.artist, metadata.artist
|
|
);
|
|
}
|
|
|
|
title_changed || artist_changed || album_changed
|
|
})
|
|
.unwrap_or(false); // Only true if there was previous metadata AND it differs
|
|
|
|
if is_first_metadata || track_changed {
|
|
tracing::info!(
|
|
"MusicRenderer [{}]: {} ({:?} -> {:?}), resetting track_start_time",
|
|
self.info.friendly_name(),
|
|
if is_first_metadata {
|
|
"First metadata"
|
|
} else {
|
|
"Track changed"
|
|
},
|
|
watched.metadata.as_ref().and_then(|m| m.title.as_ref()),
|
|
metadata.title
|
|
);
|
|
// Drop the lock before calling set_last_metadata to avoid nested locking
|
|
drop(watched);
|
|
self.set_last_metadata(Some(metadata.clone()));
|
|
watched = self
|
|
.watched_state
|
|
.lock()
|
|
.expect("WatchedState mutex poisoned");
|
|
}
|
|
|
|
// Emit event without holding watched lock
|
|
let metadata_clone = metadata.clone();
|
|
drop(watched);
|
|
self.emit_event(RendererEvent::MetadataChanged {
|
|
id: self.id(),
|
|
metadata: metadata_clone,
|
|
});
|
|
watched = self
|
|
.watched_state
|
|
.lock()
|
|
.expect("WatchedState mutex poisoned");
|
|
watched.metadata = Some(metadata);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle position updates
|
|
if let Some(mut position) = position {
|
|
// For continuous streams, manage duration to prevent it from decreasing
|
|
if let Some(stream_flag) = is_stream {
|
|
if stream_flag {
|
|
if let Some(ref new_duration) = position.track_duration {
|
|
let mut state = self.state.lock().unwrap();
|
|
|
|
// Parse durations to compare (HH:MM:SS format)
|
|
let parse_duration = |dur_str: &str| -> Option<u32> {
|
|
let parts: Vec<&str> = dur_str.split(':').collect();
|
|
if parts.len() == 3 {
|
|
let h: u32 = parts[0].parse().ok()?;
|
|
let m: u32 = parts[1].parse().ok()?;
|
|
let s: u32 = parts[2].parse().ok()?;
|
|
Some(h * 3600 + m * 60 + s)
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
match &state.current_track_duration {
|
|
Some(stored_duration) => {
|
|
// Compare new duration with stored one
|
|
if let (Some(stored_secs), Some(new_secs)) = (
|
|
parse_duration(stored_duration),
|
|
parse_duration(new_duration),
|
|
) {
|
|
if new_secs > stored_secs {
|
|
// Duration increased: update stored value and use new one
|
|
tracing::debug!(
|
|
"MusicRenderer [{}]: Stream duration increased: {} -> {}",
|
|
self.info.friendly_name(),
|
|
stored_duration,
|
|
new_duration
|
|
);
|
|
state.current_track_duration = Some(new_duration.clone());
|
|
} else {
|
|
// Duration decreased or equal: keep stored value
|
|
position.track_duration = Some(stored_duration.clone());
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
// First time: store the duration
|
|
state.current_track_duration = Some(new_duration.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let changed = watched
|
|
.position
|
|
.as_ref()
|
|
.map(|prev| {
|
|
let equal = playback_position_equal(prev, &position);
|
|
if !equal {
|
|
tracing::trace!(
|
|
"MusicRenderer [{}]: Position changed - prev_rel_time={:?}, new_rel_time={:?}",
|
|
self.info.friendly_name(),
|
|
prev.rel_time,
|
|
position.rel_time
|
|
);
|
|
}
|
|
!equal
|
|
})
|
|
.unwrap_or(true);
|
|
|
|
if changed {
|
|
let position_clone = position.clone();
|
|
drop(watched);
|
|
self.emit_event(RendererEvent::PositionChanged {
|
|
id: self.id(),
|
|
position: position_clone,
|
|
});
|
|
watched = self
|
|
.watched_state
|
|
.lock()
|
|
.expect("WatchedState mutex poisoned");
|
|
}
|
|
|
|
watched.position = Some(position);
|
|
}
|
|
|
|
// Poll state every tick
|
|
// Note: Device-specific bugs (like Arylic/LinkPlay reporting STOPPED while playing)
|
|
// should be corrected in the backend's playback_state() method, not here.
|
|
if let Some(raw_state) = raw_state {
|
|
let changed = watched
|
|
.state
|
|
.as_ref()
|
|
.map(|prev| !playback_state_equal(prev, &raw_state))
|
|
.unwrap_or(true);
|
|
|
|
// Emit event for all state changes including Transitioning
|
|
if changed {
|
|
tracing::trace!(
|
|
renderer = self.info.friendly_name(),
|
|
prev_state = ?watched.state,
|
|
new_state = ?raw_state,
|
|
"Playback state changed"
|
|
);
|
|
let state_clone = raw_state.clone();
|
|
drop(watched);
|
|
self.emit_event(RendererEvent::StateChanged {
|
|
id: self.id(),
|
|
state: state_clone.clone(),
|
|
});
|
|
|
|
// Handle auto-advance logic internally
|
|
self.handle_state_change(&state_clone);
|
|
|
|
watched = self
|
|
.watched_state
|
|
.lock()
|
|
.expect("WatchedState mutex poisoned");
|
|
}
|
|
|
|
watched.state = Some(raw_state);
|
|
}
|
|
|
|
// Handle volume/mute updates (polled every other tick)
|
|
if let Some(vol) = volume {
|
|
if watched.volume != Some(vol) {
|
|
drop(watched);
|
|
self.emit_event(RendererEvent::VolumeChanged {
|
|
id: self.id(),
|
|
volume: vol,
|
|
});
|
|
watched = self
|
|
.watched_state
|
|
.lock()
|
|
.expect("WatchedState mutex poisoned");
|
|
watched.volume = Some(vol);
|
|
}
|
|
}
|
|
|
|
if let Some(m) = mute {
|
|
if watched.mute != Some(m) {
|
|
drop(watched);
|
|
self.emit_event(RendererEvent::MuteChanged {
|
|
id: self.id(),
|
|
mute: m,
|
|
});
|
|
watched = self
|
|
.watched_state
|
|
.lock()
|
|
.expect("WatchedState mutex poisoned");
|
|
watched.mute = Some(m);
|
|
}
|
|
}
|
|
|
|
// Check stream state (every other tick to avoid excessive polling)
|
|
if let Some(stream) = is_stream {
|
|
if watched.is_stream != Some(stream) {
|
|
tracing::info!(
|
|
"Stream state changed for renderer {}: is_stream={}",
|
|
self.id().0,
|
|
stream
|
|
);
|
|
drop(watched);
|
|
self.emit_event(RendererEvent::StreamStateChanged {
|
|
id: self.id(),
|
|
is_stream: stream,
|
|
});
|
|
watched = self
|
|
.watched_state
|
|
.lock()
|
|
.expect("WatchedState mutex poisoned");
|
|
watched.is_stream = Some(stream);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handles playback state changes internally (auto-advance logic).
|
|
///
|
|
/// This method is called by the watcher when a state change is detected.
|
|
/// It handles the auto-advance behavior when playback stops.
|
|
fn handle_state_change(&self, state: &PlaybackState) {
|
|
match state {
|
|
PlaybackState::Stopped => {
|
|
{
|
|
let s = self.state.lock().unwrap();
|
|
tracing::debug!(
|
|
renderer = self.info.friendly_name(),
|
|
has_played = s.has_played_since_track_start,
|
|
playback_source = ?s.playback_source,
|
|
user_stop_requested = s.user_stop_requested,
|
|
"STOPPED detected — evaluating auto-advance"
|
|
);
|
|
}
|
|
// Check if user requested stop (via Stop button in UI)
|
|
if self.check_and_clear_user_stop_requested() {
|
|
debug!(
|
|
renderer = self.info.friendly_name(),
|
|
"Renderer stopped by user request; not auto-advancing"
|
|
);
|
|
self.set_playback_source(PlaybackSource::None);
|
|
self.clear_has_played_flag();
|
|
} else if self.is_playing_a_stream() {
|
|
// Continuous stream (radio) stopped naturally — never auto-advance
|
|
debug!(
|
|
renderer = self.info.friendly_name(),
|
|
"Renderer stopped during continuous stream; not auto-advancing"
|
|
);
|
|
self.set_playback_source(PlaybackSource::None);
|
|
self.clear_has_played_flag();
|
|
} else if self.is_playing_from_queue() {
|
|
let has_played = self.check_and_clear_has_played_flag();
|
|
|
|
// ✅ CORRECTION BUG PRODUCTION: Garde fou contre latence DB
|
|
// Si la base de données est saturée (mutex >400ms) on rate parfois l'événement PLAYING.
|
|
// On autorise l'auto-avance si:
|
|
// 1. On a bien vu PLAYING OU
|
|
// 2. Le titre a été lancé depuis plus de 20 secondes
|
|
let track_start = self.state.lock().unwrap().track_start_time;
|
|
let elapsed = track_start
|
|
.and_then(|t| t.elapsed().ok())
|
|
.unwrap_or_default();
|
|
let force_advance = elapsed.as_secs() > 20;
|
|
|
|
if has_played || force_advance {
|
|
if force_advance {
|
|
debug!(
|
|
renderer = self.info.friendly_name(),
|
|
elapsed_sec = elapsed.as_secs(),
|
|
"⚠️ On force l'auto-avance: on a raté l'événement PLAYING (latence DB)"
|
|
);
|
|
} else {
|
|
debug!(
|
|
renderer = self.info.friendly_name(),
|
|
"Renderer stopped after queue-driven playback; advancing"
|
|
);
|
|
}
|
|
// Use catch_unwind to prevent panics in play_next_from_queue
|
|
// from poisoning the backend mutex
|
|
// Also add retry logic for transient errors from renderer
|
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
self.play_next_from_queue_with_retry()
|
|
}));
|
|
match result {
|
|
Ok(Ok(())) => {}
|
|
Ok(Err(err)) => {
|
|
error!(
|
|
renderer = self.info.friendly_name(),
|
|
error = %err,
|
|
"Auto-advance failed after retries; clearing queue playback state"
|
|
);
|
|
self.set_playback_source(PlaybackSource::None);
|
|
}
|
|
Err(_panic) => {
|
|
error!(
|
|
renderer = self.info.friendly_name(),
|
|
"Auto-advance panicked; clearing queue playback state"
|
|
);
|
|
self.set_playback_source(PlaybackSource::None);
|
|
}
|
|
}
|
|
} else {
|
|
debug!(
|
|
renderer = self.info.friendly_name(),
|
|
"Renderer stopped but no PLAYING state seen yet; ignoring (likely track initialization)"
|
|
);
|
|
}
|
|
} else {
|
|
self.set_playback_source(PlaybackSource::None);
|
|
self.clear_has_played_flag();
|
|
}
|
|
}
|
|
PlaybackState::NoMedia => {
|
|
// Handle end of track (Chromecast returns NoMedia when track ends)
|
|
// This is equivalent to Stopped for auto-advance purposes
|
|
let s = self.state.lock().unwrap();
|
|
let playback_source = s.playback_source;
|
|
let has_played = s.has_played_since_track_start;
|
|
let user_stop = s.user_stop_requested;
|
|
drop(s);
|
|
|
|
tracing::debug!(
|
|
renderer = self.info.friendly_name(),
|
|
has_played = has_played,
|
|
playback_source = ?playback_source,
|
|
user_stop_requested = user_stop,
|
|
"NoMedia detected — evaluating auto-advance"
|
|
);
|
|
|
|
// Check if user requested stop (via Stop button in UI)
|
|
if self.check_and_clear_user_stop_requested() {
|
|
debug!(
|
|
renderer = self.info.friendly_name(),
|
|
"NoMedia after user request; not auto-advancing"
|
|
);
|
|
self.set_playback_source(PlaybackSource::None);
|
|
self.clear_has_played_flag();
|
|
} else if matches!(playback_source, PlaybackSource::FromQueue) {
|
|
// Auto-advance if we have seen a PLAYING state
|
|
if self.check_and_clear_has_played_flag() {
|
|
debug!(
|
|
renderer = self.info.friendly_name(),
|
|
"NoMedia after queue-driven playback; advancing to next track"
|
|
);
|
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
self.play_next_from_queue()
|
|
}));
|
|
match result {
|
|
Ok(Ok(())) => {}
|
|
Ok(Err(err)) => {
|
|
error!(
|
|
renderer = self.info.friendly_name(),
|
|
error = %err,
|
|
"Auto-advance from NoMedia failed; clearing queue playback state"
|
|
);
|
|
self.set_playback_source(PlaybackSource::None);
|
|
}
|
|
Err(_panic) => {
|
|
error!(
|
|
renderer = self.info.friendly_name(),
|
|
"Auto-advance from NoMedia panicked; clearing queue playback state"
|
|
);
|
|
self.set_playback_source(PlaybackSource::None);
|
|
}
|
|
}
|
|
} else {
|
|
tracing::debug!(
|
|
renderer = self.info.friendly_name(),
|
|
"NoMedia but no PLAYING state seen yet; ignoring"
|
|
);
|
|
}
|
|
} else {
|
|
self.set_playback_source(PlaybackSource::None);
|
|
self.clear_has_played_flag();
|
|
}
|
|
}
|
|
PlaybackState::Playing => {
|
|
self.mark_external_if_idle();
|
|
// Mark that we have seen a PLAYING state - auto-advance is now allowed
|
|
self.set_has_played_flag();
|
|
}
|
|
PlaybackState::Transitioning => {
|
|
let s = self.state.lock().unwrap();
|
|
tracing::trace!(
|
|
renderer = self.info.friendly_name(),
|
|
has_played = s.has_played_since_track_start,
|
|
playback_source = ?s.playback_source,
|
|
"TRANSITIONING detected"
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Acquires the backend mutex, recovering from poisoned state if necessary.
|
|
///
|
|
/// This method handles the case where the mutex was poisoned by a panic in another thread.
|
|
/// Instead of panicking, it recovers the inner data and logs a warning with context.
|
|
///
|
|
/// # Arguments
|
|
/// * `context` - A description of the operation attempting to acquire the lock
|
|
fn lock_backend_for(&self, context: &str) -> std::sync::MutexGuard<'_, MusicRendererBackend> {
|
|
match self.backend.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => {
|
|
warn!(
|
|
renderer = self.info.friendly_name(),
|
|
context = context,
|
|
"Backend mutex was poisoned, recovering"
|
|
);
|
|
poisoned.into_inner()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Acquires the backend mutex with a default context message.
|
|
///
|
|
/// Convenience wrapper around `lock_backend_for` for simple cases.
|
|
#[allow(dead_code)]
|
|
fn lock_backend(&self) -> std::sync::MutexGuard<'_, MusicRendererBackend> {
|
|
self.lock_backend_for("unknown operation")
|
|
}
|
|
|
|
pub fn info(&self) -> &RendererInfo {
|
|
&self.info
|
|
}
|
|
|
|
/// Returns the protocol.
|
|
pub fn protocol(&self) -> RendererProtocol {
|
|
self.info.protocol()
|
|
}
|
|
|
|
pub fn is_upnp(&self) -> bool {
|
|
match &*self.lock_backend_for("is_upnp") {
|
|
MusicRendererBackend::Upnp(_) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
pub fn is_openhome(&self) -> bool {
|
|
match &*self.lock_backend_for("is_openhome") {
|
|
MusicRendererBackend::OpenHome(_) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
pub fn is_linkplay(&self) -> bool {
|
|
match &*self.lock_backend_for("is_linkplay") {
|
|
MusicRendererBackend::LinkPlay(_) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
pub fn is_arylictcp(&self) -> bool {
|
|
match &*self.lock_backend_for("is_arylictcp") {
|
|
MusicRendererBackend::ArylicTcp(_) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
pub fn is_chromecast(&self) -> bool {
|
|
match &*self.lock_backend_for("is_chromecast") {
|
|
MusicRendererBackend::Chromecast(_) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
pub fn is_hybridupnparylic(&self) -> bool {
|
|
match &*self.lock_backend_for("is_hybridupnparylic") {
|
|
MusicRendererBackend::HybridUpnpArylic { .. } => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Returns true if this renderer is known to support SetNextAVTransportURI.
|
|
pub fn supports_set_next(&self) -> bool {
|
|
self.info.capabilities().supports_set_next()
|
|
}
|
|
|
|
/// Returns true if the current track is a continuous stream (radio without duration).
|
|
///
|
|
/// This method queries the backend's cached stream detection flag. The detection is based
|
|
/// on HTTP headers analysis performed when the URL was set via play_uri or when the track
|
|
/// changed (for OpenHome).
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// `true` if the current track is a continuous stream (radio, live broadcast, etc.)
|
|
/// `false` if playing a bounded media file or no track loaded
|
|
///
|
|
/// Note: This returns the stream status of the current track regardless of playback state
|
|
/// (playing, paused, or stopped).
|
|
pub fn is_playing_a_stream(&self) -> bool {
|
|
let backend = self.lock_backend_for("is_playing_a_stream");
|
|
|
|
// Query backend for stream status (already cached by backends)
|
|
match &*backend {
|
|
MusicRendererBackend::Upnp(upnp) => upnp.is_continuous_stream(),
|
|
MusicRendererBackend::OpenHome(oh) => oh.is_continuous_stream(),
|
|
MusicRendererBackend::LinkPlay(lp) => lp.is_continuous_stream(),
|
|
MusicRendererBackend::ArylicTcp(ary) => ary.is_continuous_stream(),
|
|
MusicRendererBackend::Chromecast(cc) => cc.is_continuous_stream(),
|
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.is_continuous_stream(),
|
|
}
|
|
}
|
|
|
|
/// Prepare the renderer for attaching a new playlist by clearing the queue and stopping playback.
|
|
pub fn clear_for_playlist_attach(&self) -> Result<(), ControlPointError> {
|
|
let mut backend = self.lock_backend_for("clear_for_playlist_attach");
|
|
|
|
// Clear the queue first (ignore errors - queue will be replaced anyway by sync_queue)
|
|
// Some backends (OpenHome) may reject DeleteAll if currently playing
|
|
if let Err(err) = backend.clear_queue() {
|
|
warn!(
|
|
renderer = self.id().0.as_str(),
|
|
error = %err,
|
|
"Clear queue failed when preparing for playlist attach (continuing anyway)"
|
|
);
|
|
}
|
|
|
|
// Then stop playback (ignore errors if already stopped)
|
|
tracing::trace!(
|
|
renderer = self.id().0.as_str(),
|
|
"STOP command via clear_for_playlist_attach"
|
|
);
|
|
backend.stop().or_else(|err| {
|
|
warn!(
|
|
renderer = self.id().0.as_str(),
|
|
error = %err,
|
|
"Stop failed when preparing for playlist attach (continuing anyway)"
|
|
);
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
/// Get the current queue snapshot.
|
|
pub fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
|
let mut snapshot = self.lock_backend_for("queue_snapshot").queue_snapshot()?;
|
|
|
|
// Enrich snapshot with playlist_id from binding if available
|
|
if let Some(binding) = self
|
|
.playlist_binding
|
|
.lock()
|
|
.expect("Binding mutex poisoned")
|
|
.as_ref()
|
|
{
|
|
// Use container_id as the playlist identifier
|
|
snapshot.playlist_id = Some(binding.container_id.clone());
|
|
}
|
|
|
|
Ok(snapshot)
|
|
}
|
|
|
|
/// Get a clone of the queue Arc (for async sync operations).
|
|
pub fn queue(&self) -> Arc<Mutex<MusicQueue>> {
|
|
let backend = self.lock_backend_for("queue");
|
|
crate::music_renderer::capabilities::RendererBackend::queue(&*backend).clone()
|
|
}
|
|
|
|
/// Get the current queue item without advancing.
|
|
/// Returns the item and count of remaining items after current.
|
|
pub fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
|
self.lock_backend_for("peek_current").peek_current()
|
|
}
|
|
|
|
/// Get the count of items remaining after the current index.
|
|
pub fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
|
self.lock_backend_for("upcoming_len").upcoming_len()
|
|
}
|
|
|
|
/// Play the current item from the queue with retry logic for transient renderer errors.
|
|
///
|
|
/// Some renderers (like JBL Authentics 300) may fail the first Play command
|
|
/// due to timing issues. This method retries with a small delay.
|
|
pub fn play_current_from_queue_with_retry(&self) -> Result<(), ControlPointError> {
|
|
const MAX_RETRIES: usize = 3;
|
|
const RETRY_DELAY_MS: u64 = 200;
|
|
|
|
let mut last_error = None;
|
|
|
|
for attempt in 0..MAX_RETRIES {
|
|
// Reset the has_played flag before starting playback to prevent
|
|
// auto-advance on transient STOPPED states during track initialization.
|
|
// The flag will be set back to true when PLAYING state is detected.
|
|
self.clear_has_played_flag();
|
|
|
|
// Set playback_source to FromQueue BEFORE calling backend
|
|
// to prevent race condition where watcher sees STOPPED before
|
|
// source is set, breaking auto-advance
|
|
self.set_playback_source(PlaybackSource::FromQueue);
|
|
|
|
match self
|
|
.lock_backend_for("play_current_from_queue_with_retry")
|
|
.play_from_queue()
|
|
{
|
|
Ok(()) => return Ok(()),
|
|
Err(e) => {
|
|
last_error = Some(e);
|
|
if attempt < MAX_RETRIES - 1 {
|
|
tracing::warn!(
|
|
renderer = self.info.friendly_name(),
|
|
attempt = attempt + 1,
|
|
error = %last_error.as_ref().unwrap(),
|
|
"Initial play attempt failed, retrying..."
|
|
);
|
|
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// All retries failed
|
|
Err(last_error.unwrap_or_else(|| {
|
|
ControlPointError::ControlPoint("Unknown error in retry logic".into())
|
|
}))
|
|
}
|
|
|
|
/// Play the current item from the queue.
|
|
#[deprecated(
|
|
since = "0.1.0",
|
|
note = "Use play_current_from_queue_with_retry instead"
|
|
)]
|
|
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
|
|
// Reset the has_played_flag before starting playback to prevent
|
|
// auto-advance on transient STOPPED states during track initialization.
|
|
// The flag will be set back to true when PLAYING state is detected.
|
|
self.clear_has_played_flag();
|
|
|
|
// Set playback_source to FromQueue BEFORE calling backend
|
|
// to prevent race condition where watcher sees STOPPED before
|
|
// source is set, breaking auto-advance
|
|
self.set_playback_source(PlaybackSource::FromQueue);
|
|
|
|
self.lock_backend_for("play_current_from_queue")
|
|
.play_from_queue()
|
|
}
|
|
|
|
/// Advance to and play the next item from the queue.
|
|
pub fn play_next_from_queue(&self) -> Result<(), ControlPointError> {
|
|
// Reset the has_played flag before starting playback to prevent
|
|
// auto-advance on transient STOPPED states during track initialization.
|
|
// The flag will be set back to true when PLAYING state is detected.
|
|
self.clear_has_played_flag();
|
|
|
|
// Set playback_source to FromQueue BEFORE calling backend
|
|
// to prevent race condition where watcher sees STOPPED before
|
|
// source is set, breaking auto-advance
|
|
self.set_playback_source(PlaybackSource::FromQueue);
|
|
|
|
self.lock_backend_for("play_next_from_queue").play_next()?;
|
|
self.emit_queue_updated();
|
|
Ok(())
|
|
}
|
|
|
|
/// Play next from queue with retry logic for transient renderer errors.
|
|
///
|
|
/// Some renderers (like JBL Authentics 300) may fail the first Play command
|
|
/// due to timing issues. This method retries with a small delay.
|
|
pub fn play_next_from_queue_with_retry(&self) -> Result<(), ControlPointError> {
|
|
const MAX_RETRIES: usize = 3;
|
|
const RETRY_DELAY_MS: u64 = 200;
|
|
|
|
let mut last_error = None;
|
|
|
|
for attempt in 0..MAX_RETRIES {
|
|
match self.play_next_from_queue() {
|
|
Ok(()) => return Ok(()),
|
|
Err(e) => {
|
|
last_error = Some(e);
|
|
if attempt < MAX_RETRIES - 1 {
|
|
tracing::warn!(
|
|
renderer = self.info.friendly_name(),
|
|
attempt = attempt + 1,
|
|
error = %last_error.as_ref().unwrap(),
|
|
"Auto-advance attempt failed, retrying..."
|
|
);
|
|
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// All retries failed
|
|
Err(last_error.unwrap_or_else(|| {
|
|
ControlPointError::ControlPoint("Unknown error in retry logic".into())
|
|
}))
|
|
}
|
|
|
|
/// Advance the queue index by one without starting playback.
|
|
///
|
|
/// Used by the WebRenderer gapless path: the browser autonomously transitions
|
|
/// to the next track, so only the backend queue pointer needs to be updated
|
|
/// to stay in sync.
|
|
pub fn advance_queue_index(&self) -> Result<bool, ControlPointError> {
|
|
let mut backend = self.lock_backend_for("advance_queue_index");
|
|
let advanced = backend.advance()?;
|
|
drop(backend);
|
|
if advanced {
|
|
self.emit_queue_updated();
|
|
}
|
|
Ok(advanced)
|
|
}
|
|
|
|
/// Play from a specific index in the queue.
|
|
pub fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
|
// ✅ CORRECTIF BUG SHUFFLE: Quand on change d'index manuellement
|
|
// (par exemple shuffle, clique sur un titre), on réinitialise OBLIGATOIREMENT
|
|
// le flag has_played. Sinon quand le titre se termine l'auto-avance
|
|
// pense qu'il n'a jamais démarré et s'arrête.
|
|
tracing::debug!(
|
|
index = index,
|
|
renderer = self.info.friendly_name(),
|
|
"🎯 play_from_index appelé, réinitialisation has_played_flag"
|
|
);
|
|
self.clear_has_played_flag();
|
|
|
|
self.lock_backend_for("play_from_index")
|
|
.play_from_index(index)?;
|
|
|
|
self.set_playback_source(PlaybackSource::FromQueue);
|
|
Ok(())
|
|
}
|
|
|
|
/// Transport control: play
|
|
///
|
|
/// Démarre ou reprend la lecture. Si une queue non vide existe,
|
|
/// joue le track courant de la queue automatiquement (comportement unifié pour tous les backends).
|
|
pub fn play(&self) -> Result<(), ControlPointError> {
|
|
// Mark renderer as active for adaptive polling
|
|
{
|
|
let mut watched = self
|
|
.watched_state
|
|
.lock()
|
|
.expect("WatchedState mutex poisoned");
|
|
watched.is_active = true;
|
|
}
|
|
|
|
// Vérifier si on a une queue non vide
|
|
let backend = self.lock_backend_for("play");
|
|
let queue_not_empty = backend.len().unwrap_or(0) > 0;
|
|
|
|
if queue_not_empty {
|
|
// Set playback_source to FromQueue BEFORE calling backend
|
|
// to prevent race condition where watcher sees STOPPED before
|
|
// source is set, breaking auto-advance
|
|
self.set_playback_source(PlaybackSource::FromQueue);
|
|
|
|
// Si on a des items dans la queue, jouer le track courant (ou le premier si aucun n'est sélectionné)
|
|
// Cela fonctionne pour tous les backends (UPnP interne, OpenHome, etc.)
|
|
backend.play_from_queue()
|
|
} else {
|
|
// Queue vide : déléguer au backend (reprend la lecture en cours, etc.)
|
|
backend.play()
|
|
}
|
|
}
|
|
|
|
/// Transport control: pause
|
|
pub fn pause(&self) -> Result<(), ControlPointError> {
|
|
self.lock_backend_for("pause").pause()
|
|
}
|
|
|
|
/// Transport control: stop
|
|
#[track_caller]
|
|
pub fn stop(&self) -> Result<(), ControlPointError> {
|
|
// Mark renderer as active for adaptive polling
|
|
{
|
|
let mut watched = self
|
|
.watched_state
|
|
.lock()
|
|
.expect("WatchedState mutex poisoned");
|
|
watched.is_active = true;
|
|
}
|
|
|
|
// Reset the has_played flag when stopping playback.
|
|
// This ensures that if we start a new track, the flag will be false
|
|
// until PLAYING state is observed, preventing auto-advance on
|
|
// transient STOPPED states during track initialization.
|
|
self.clear_has_played_flag();
|
|
|
|
let caller = std::panic::Location::caller();
|
|
tracing::trace!(
|
|
renderer = self.info.friendly_name(),
|
|
caller = %caller,
|
|
"STOP command sent to renderer"
|
|
);
|
|
self.lock_backend_for("stop").stop()
|
|
}
|
|
|
|
/// Set the next URI for gapless playback (UPnP AVTransport only).
|
|
///
|
|
/// Returns Ok if the backend supports this feature and it succeeded.
|
|
/// Returns Err if not supported or if it failed.
|
|
pub fn set_next_uri(&self, uri: &str, metadata: &str) -> Result<(), ControlPointError> {
|
|
let backend = self.lock_backend_for("set_next_uri");
|
|
|
|
match &*backend {
|
|
MusicRendererBackend::Upnp(upnp) => upnp.set_next_uri(uri, metadata),
|
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_next_uri(uri, metadata),
|
|
_ => Err(ControlPointError::ControlPoint(
|
|
"SetNextURI not supported by this backend".to_string(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Transport control: seek to relative time
|
|
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
|
// Mark renderer as active for adaptive polling
|
|
{
|
|
let mut watched = self
|
|
.watched_state
|
|
.lock()
|
|
.expect("WatchedState mutex poisoned");
|
|
watched.is_active = true;
|
|
}
|
|
|
|
self.lock_backend_for("seek_rel_time").seek_rel_time(hhmmss)
|
|
}
|
|
|
|
/// Seek to a specific position in seconds
|
|
pub fn seek(&self, seconds: u32) -> Result<(), ControlPointError> {
|
|
// Convert seconds to HH:MM:SS format
|
|
let hours = seconds / 3600;
|
|
let minutes = (seconds % 3600) / 60;
|
|
let secs = seconds % 60;
|
|
let hhmmss = format!("{:02}:{:02}:{:02}", hours, minutes, secs);
|
|
self.seek_rel_time(&hhmmss)
|
|
}
|
|
|
|
/// Volume control: get current volume
|
|
pub fn volume(&self) -> Result<u16, ControlPointError> {
|
|
self.lock_backend_for("volume").volume()
|
|
}
|
|
|
|
/// Volume control: set volume
|
|
pub fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> {
|
|
self.lock_backend_for("set_volume").set_volume(vol)
|
|
}
|
|
|
|
/// Volume control: get mute state
|
|
pub fn mute(&self) -> Result<bool, ControlPointError> {
|
|
self.lock_backend_for("mute").mute()
|
|
}
|
|
|
|
/// Volume control: set mute state
|
|
pub fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
|
self.lock_backend_for("set_mute").set_mute(m)
|
|
}
|
|
|
|
/// Get playback state
|
|
pub fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
|
self.lock_backend_for("playback_state").playback_state()
|
|
}
|
|
|
|
/// Get playback position
|
|
///
|
|
/// This method patches the position info from the backend:
|
|
/// - If track_duration is None, try to extract it from DIDL metadata
|
|
/// - Calculates rel_time from track_start_time (backend values are unreliable)
|
|
///
|
|
/// Note: track_start_time is updated by poll_and_emit_changes when track changes
|
|
pub fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
|
let mut position_info = self
|
|
.lock_backend_for("playback_position")
|
|
.playback_position()?;
|
|
|
|
// Si track_duration est absent, essayer de le parser depuis le DIDL metadata
|
|
if position_info.track_duration.is_none() {
|
|
if let Some(ref metadata_xml) = position_info.track_metadata {
|
|
if let Some(duration) = parse_didl_duration(metadata_xml) {
|
|
tracing::debug!(
|
|
"MusicRenderer: Corrected track_duration from DIDL metadata: {}",
|
|
duration
|
|
);
|
|
position_info.track_duration = Some(duration);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pour les flux continus uniquement : calculer rel_time depuis track_start_time
|
|
// Pour les fichiers normaux : garder les valeurs du backend
|
|
// IMPORTANT: Ne calculer le temps que si le lecteur est en PLAYING
|
|
let is_stream = self.is_playing_a_stream();
|
|
if is_stream {
|
|
// Vérifier que le lecteur est en lecture avant de calculer le temps écoulé
|
|
let is_playing = matches!(self.playback_state().ok(), Some(PlaybackState::Playing));
|
|
|
|
if is_playing {
|
|
if let Some(start_time) = self.track_start_time() {
|
|
if let Ok(elapsed) = start_time.elapsed() {
|
|
let secs = elapsed.as_secs() as u32;
|
|
let hours = secs / 3600;
|
|
let minutes = (secs % 3600) / 60;
|
|
let seconds = secs % 60;
|
|
let new_rel_time = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
|
|
tracing::debug!(
|
|
"MusicRenderer [{}]: Stream - calculating rel_time from track_start_time: {} (elapsed={}s)",
|
|
self.info.friendly_name(),
|
|
new_rel_time,
|
|
secs
|
|
);
|
|
position_info.rel_time = Some(new_rel_time);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(position_info)
|
|
}
|
|
|
|
/// Sets the playlist binding for this renderer.
|
|
/// Emits a `BindingChanged` event only if the binding actually changes.
|
|
pub fn set_playlist_binding(&self, binding: Option<PlaylistBinding>) {
|
|
let mut guard = self
|
|
.playlist_binding
|
|
.lock()
|
|
.expect("Playlist binding mutex poisoned");
|
|
|
|
// Check if there's an actual change (both None, or different Some values)
|
|
let old_is_some = guard.is_some();
|
|
let new_is_some = binding.is_some();
|
|
let has_changed = old_is_some != new_is_some || (old_is_some && new_is_some);
|
|
|
|
*guard = binding.clone();
|
|
drop(guard);
|
|
|
|
if has_changed {
|
|
self.emit_binding_changed(binding);
|
|
}
|
|
}
|
|
|
|
/// Gets the current playlist binding, if any.
|
|
pub fn get_playlist_binding(&self) -> Option<PlaylistBinding> {
|
|
self.playlist_binding
|
|
.lock()
|
|
.expect("Playlist binding mutex poisoned")
|
|
.clone()
|
|
}
|
|
|
|
/// Clears the playlist binding.
|
|
/// Emits a `BindingChanged` event with `None` only if there was a binding to clear.
|
|
pub fn clear_playlist_binding(&self) {
|
|
let mut guard = self
|
|
.playlist_binding
|
|
.lock()
|
|
.expect("Playlist binding mutex poisoned");
|
|
|
|
let had_binding = guard.is_some();
|
|
*guard = None;
|
|
drop(guard);
|
|
|
|
if had_binding {
|
|
self.emit_binding_changed(None);
|
|
}
|
|
}
|
|
|
|
/// Helper method to emit a BindingChanged event if an event bus is available.
|
|
fn emit_binding_changed(&self, binding: Option<PlaylistBinding>) {
|
|
if let Some(ref bus) = self.event_bus {
|
|
bus.broadcast(RendererEvent::BindingChanged {
|
|
id: self.id(),
|
|
binding,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Marks the current playlist binding for refresh if it matches the given server and container.
|
|
/// Returns true if a matching binding was found and marked.
|
|
pub fn mark_binding_for_refresh(&self, server_id: &DeviceId, container_ids: &[String]) -> bool {
|
|
let mut binding_guard = self
|
|
.playlist_binding
|
|
.lock()
|
|
.expect("Playlist binding mutex poisoned");
|
|
|
|
if let Some(binding) = binding_guard.as_mut() {
|
|
if &binding.server_id == server_id && container_ids.contains(&binding.container_id) {
|
|
binding.pending_refresh = true;
|
|
binding.has_seen_update = true;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
/// Checks if the binding has pending_refresh flag set.
|
|
/// Returns false if no binding exists.
|
|
pub fn has_pending_refresh(&self) -> bool {
|
|
self.playlist_binding
|
|
.lock()
|
|
.expect("Playlist binding mutex poisoned")
|
|
.as_ref()
|
|
.map(|b| b.pending_refresh)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Resets the pending_refresh flag to false.
|
|
/// Does nothing if no binding exists.
|
|
pub fn reset_pending_refresh(&self) {
|
|
if let Some(binding) = self
|
|
.playlist_binding
|
|
.lock()
|
|
.expect("Playlist binding mutex poisoned")
|
|
.as_mut()
|
|
{
|
|
binding.pending_refresh = false;
|
|
}
|
|
}
|
|
|
|
/// Marks the binding as pending refresh.
|
|
/// Returns true if a binding exists and was marked, false otherwise.
|
|
pub fn mark_pending_refresh(&self) -> bool {
|
|
self.playlist_binding
|
|
.lock()
|
|
.expect("Playlist binding mutex poisoned")
|
|
.as_mut()
|
|
.map(|binding| {
|
|
binding.pending_refresh = true;
|
|
true
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Consumes and returns the auto_play_on_refresh flag, resetting it to false.
|
|
/// Returns false if no binding exists.
|
|
pub fn consume_auto_play(&self) -> bool {
|
|
self.playlist_binding
|
|
.lock()
|
|
.expect("Playlist binding mutex poisoned")
|
|
.as_mut()
|
|
.map(|binding| {
|
|
let auto_play = binding.auto_play_on_refresh;
|
|
binding.auto_play_on_refresh = false;
|
|
auto_play
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Returns the number of items in the queue.
|
|
pub fn len(&self) -> Result<usize, ControlPointError> {
|
|
self.lock_backend_for("len").len()
|
|
}
|
|
|
|
/// Add items to the queue using the specified enqueue mode.
|
|
pub fn enqueue_items(
|
|
&self,
|
|
items: Vec<PlaybackItem>,
|
|
mode: EnqueueMode,
|
|
) -> Result<(), ControlPointError> {
|
|
let mut backend = self.lock_backend_for("enqueue_items");
|
|
backend.enqueue_items(items, mode)?;
|
|
drop(backend);
|
|
self.emit_queue_updated();
|
|
Ok(())
|
|
}
|
|
|
|
/// Synchronize the queue with new items while preserving the current track.
|
|
///
|
|
/// This method intelligently updates the queue:
|
|
/// - If the current track is in the new items, it keeps playing at the new position
|
|
/// - 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
|
|
pub fn sync_queue(&self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
|
self.sync_queue_with_callback(items, None, None)
|
|
}
|
|
|
|
/// Synchronize the queue with new items with optional cancel token and on_ready callback.
|
|
pub fn sync_queue_with_callback(
|
|
&self,
|
|
items: Vec<PlaybackItem>,
|
|
cancel_token: Option<&Arc<AtomicBool>>,
|
|
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
|
) -> Result<(), ControlPointError> {
|
|
{
|
|
let mut watched = self
|
|
.watched_state
|
|
.lock()
|
|
.expect("WatchedState mutex poisoned");
|
|
watched.is_active = true;
|
|
}
|
|
|
|
let mut backend = self.lock_backend_for("sync_queue");
|
|
let dummy_token = Arc::new(AtomicBool::new(false));
|
|
let token = cancel_token.unwrap_or(&dummy_token);
|
|
backend.sync_queue(items, token, on_ready)?;
|
|
drop(backend);
|
|
self.emit_queue_updated();
|
|
Ok(())
|
|
}
|
|
|
|
/// Set the current queue index (for advanced use).
|
|
/// Note: This does NOT start playback. Use select_queue_track() to play.
|
|
pub fn set_queue_index(&self, index: Option<usize>) -> Result<(), ControlPointError> {
|
|
self.lock_backend_for("set_queue_index").set_index(index)
|
|
}
|
|
|
|
/// Clears the renderer's queue using the generic QueueBackend trait.
|
|
pub fn clear_queue(&self) -> Result<(), ControlPointError> {
|
|
let mut backend = self.lock_backend_for("clear_queue");
|
|
backend.clear_queue()?;
|
|
drop(backend);
|
|
self.emit_queue_updated();
|
|
Ok(())
|
|
}
|
|
|
|
/// Dequeues and returns the next item from the queue.
|
|
pub fn dequeue_next(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
|
self.lock_backend_for("dequeue_next").dequeue_next()
|
|
}
|
|
|
|
/// Sets the current index in the queue.
|
|
pub fn set_index(&self, index: Option<usize>) -> Result<(), ControlPointError> {
|
|
self.lock_backend_for("set_index").set_index(index)
|
|
}
|
|
|
|
/// Replaces the entire queue with new items and sets the current index.
|
|
/// This is a complete replacement, unlike sync_queue which tries to preserve the current track.
|
|
pub fn replace_queue(
|
|
&self,
|
|
items: Vec<PlaybackItem>,
|
|
current_index: Option<usize>,
|
|
) -> Result<(), ControlPointError> {
|
|
let mut backend = self.lock_backend_for("replace_queue");
|
|
backend.replace_queue(items, current_index)?;
|
|
drop(backend);
|
|
self.emit_queue_updated();
|
|
Ok(())
|
|
}
|
|
|
|
/// Adds a track to the queue.
|
|
///
|
|
/// This is primarily for backends with persistent queues (OpenHome).
|
|
/// For backends without this capability, it returns an error.
|
|
///
|
|
/// Returns the backend-specific track ID if applicable.
|
|
pub fn add_track_to_queue(
|
|
&self,
|
|
_uri: &str,
|
|
_metadata: &str,
|
|
_after_id: Option<u32>,
|
|
_play: bool,
|
|
) -> Result<Option<u32>, ControlPointError> {
|
|
// This operation requires backend-specific APIs (especially for OpenHome)
|
|
// that aren't exposed through the generic QueueBackend trait.
|
|
// The generic implementation cannot support this without more context
|
|
// (media_server_id, didl_id, protocol_info, etc.).
|
|
Err(ControlPointError::QueueError(
|
|
"add_track_to_queue requires backend-specific implementation".to_string(),
|
|
))
|
|
}
|
|
|
|
/// Selects and plays a specific track from the queue by ID.
|
|
///
|
|
/// Converts the track ID to a position using the generic QueueBackend trait,
|
|
/// then plays the track. The operation is atomic (single lock).
|
|
pub fn select_queue_track(&self, track_id: u32) -> Result<(), ControlPointError> {
|
|
// Reset the has_played flag before starting playback
|
|
self.clear_has_played_flag();
|
|
|
|
let backend = self.lock_backend_for("select_queue_track");
|
|
|
|
// Convert track_id to index and play atomically
|
|
let index = backend.id_to_position(track_id)?;
|
|
backend.play_from_index(index)?;
|
|
|
|
drop(backend);
|
|
self.emit_queue_updated();
|
|
Ok(())
|
|
}
|
|
|
|
/// Synchronizes the queue state with the backend.
|
|
///
|
|
/// For backends with persistent queues (OpenHome), this refreshes the local view.
|
|
/// For others, this is essentially a no-op (just reads the current state).
|
|
pub fn sync_queue_state(&self) -> Result<(), ControlPointError> {
|
|
// Calling queue_snapshot() triggers a refresh for backends that need it
|
|
let _ = self.lock_backend_for("sync_queue_state").queue_snapshot()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Plays the current item from the backend queue.
|
|
///
|
|
/// This is primarily for backends with persistent queues (OpenHome).
|
|
pub fn play_current_from_backend_queue(&self) -> Result<(), ControlPointError> {
|
|
// Reset the has_played flag before starting playback
|
|
self.clear_has_played_flag();
|
|
|
|
let backend = self.lock_backend_for("play_current_from_backend_queue");
|
|
|
|
// Get current track ID and convert to index atomically
|
|
let track_id = backend
|
|
.current_track()?
|
|
.ok_or_else(|| ControlPointError::QueueError("No current track".to_string()))?;
|
|
|
|
let index = backend.id_to_position(track_id)?;
|
|
|
|
// Play from that index while still holding the lock
|
|
backend.play_from_index(index)?;
|
|
|
|
drop(backend);
|
|
self.emit_queue_updated();
|
|
Ok(())
|
|
}
|
|
|
|
/// Plays from the queue at the current position.
|
|
///
|
|
/// Uses the backend's play_from_queue which preserves the queue for all backends.
|
|
pub fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
|
// Reset the has_played flag before starting playback to prevent
|
|
// auto-advance on transient STOPPED states during track initialization.
|
|
// The flag will be set back to true when PLAYING state is detected.
|
|
self.clear_has_played_flag();
|
|
|
|
// Set playback_source to FromQueue BEFORE calling backend
|
|
// to prevent race condition where watcher sees STOPPED before
|
|
// source is set, breaking auto-advance
|
|
self.set_playback_source(PlaybackSource::FromQueue);
|
|
|
|
self.lock_backend_for("play_from_queue").play_from_queue()
|
|
}
|
|
|
|
// --- Playback State Management ---
|
|
|
|
/// Gets the last known track metadata.
|
|
pub fn last_metadata(&self) -> Option<TrackMetadata> {
|
|
self.state.lock().unwrap().last_metadata.clone()
|
|
}
|
|
|
|
/// Sets the last known track metadata.
|
|
/// Updates track_start_time and resets current_track_duration only if the metadata actually changes.
|
|
pub fn set_last_metadata(&self, metadata: Option<TrackMetadata>) {
|
|
let mut state = self.state.lock().unwrap();
|
|
let metadata_changed = state.last_metadata != metadata;
|
|
if metadata_changed {
|
|
// Pour les flux continus: utiliser dc:date comme track_start_time réel de diffusion.
|
|
// Toute source stream peut encoder son start_time en RFC 3339 dans dc:date.
|
|
// Fallback sur now() si absent ou non parseable.
|
|
let start_time = metadata
|
|
.as_ref()
|
|
.filter(|m| m.is_continuous_stream)
|
|
.and_then(|m| m.date.as_deref())
|
|
.and_then(parse_rfc3339_to_system_time)
|
|
.unwrap_or_else(SystemTime::now);
|
|
state.track_start_time = Some(start_time);
|
|
// Reset duration cache when track changes (for streams)
|
|
state.current_track_duration = None;
|
|
}
|
|
state.last_metadata = metadata;
|
|
}
|
|
|
|
/// Gets the timestamp when the current track started playing.
|
|
pub fn track_start_time(&self) -> Option<SystemTime> {
|
|
self.state.lock().unwrap().track_start_time
|
|
}
|
|
|
|
/// Gets the current playback source.
|
|
pub fn playback_source(&self) -> PlaybackSource {
|
|
self.state.lock().unwrap().playback_source
|
|
}
|
|
|
|
/// Sets the playback source.
|
|
pub fn set_playback_source(&self, source: PlaybackSource) {
|
|
self.state.lock().unwrap().playback_source = source;
|
|
}
|
|
|
|
/// Checks if currently playing from queue.
|
|
pub fn is_playing_from_queue(&self) -> bool {
|
|
matches!(
|
|
self.state.lock().unwrap().playback_source,
|
|
PlaybackSource::FromQueue
|
|
)
|
|
}
|
|
|
|
/// Marks playback as external if currently idle.
|
|
///
|
|
/// Only sets to External if we were already playing from an external source.
|
|
/// Does NOT change None -> External because that would break queue playback
|
|
/// (the control_point will set it to FromQueue after play_from_queue succeeds).
|
|
pub fn mark_external_if_idle(&self) {
|
|
let mut state = self.state.lock().unwrap();
|
|
if matches!(state.playback_source, PlaybackSource::External) {
|
|
// Keep External if we were already playing externally
|
|
} else {
|
|
// Don't change None -> External - that breaks queue auto-advance!
|
|
// The control_point will set playback_source to FromQueue after play_from_queue succeeds.
|
|
}
|
|
}
|
|
|
|
/// Marks that the user requested a stop (to prevent auto-advance).
|
|
pub fn mark_user_stop_requested(&self) {
|
|
self.state.lock().unwrap().user_stop_requested = true;
|
|
}
|
|
|
|
/// Checks and clears the user stop requested flag.
|
|
pub fn check_and_clear_user_stop_requested(&self) -> bool {
|
|
let mut state = self.state.lock().unwrap();
|
|
let was_requested = state.user_stop_requested;
|
|
state.user_stop_requested = false;
|
|
was_requested
|
|
}
|
|
|
|
// --- Has-Played Flag Management (for auto-advance protection) ---
|
|
|
|
/// Sets the has_played_since_track_start flag to true.
|
|
/// Called when PLAYING state is detected.
|
|
fn set_has_played_flag(&self) {
|
|
self.state.lock().unwrap().has_played_since_track_start = true;
|
|
}
|
|
|
|
/// Clears the has_played_since_track_start flag.
|
|
/// Called when stopping playback or starting a new track.
|
|
/// This is public so that ControlPoint can reset it when jumping to a new track.
|
|
pub fn clear_has_played_flag(&self) {
|
|
self.state.lock().unwrap().has_played_since_track_start = false;
|
|
}
|
|
|
|
/// Checks and clears the has_played_since_track_start flag.
|
|
/// Returns true if PLAYING was seen since last track start, false otherwise.
|
|
/// Used to determine if auto-advance should be allowed.
|
|
fn check_and_clear_has_played_flag(&self) -> bool {
|
|
let mut state = self.state.lock().unwrap();
|
|
let has_played = state.has_played_since_track_start;
|
|
state.has_played_since_track_start = false;
|
|
has_played
|
|
}
|
|
|
|
// --- 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(),
|
|
)
|
|
}
|
|
|
|
// --- Queue Shuffle ---
|
|
|
|
/// Shuffles the current queue and restarts playback from the first track.
|
|
///
|
|
/// This method:
|
|
/// 1. Detaches the queue from any attached playlist
|
|
/// 2. Stops playback
|
|
/// 3. Takes a snapshot of the current queue
|
|
/// 4. Randomizes the order of tracks
|
|
/// 5. Replaces the queue with the shuffled items
|
|
/// 6. Starts playback from the first track
|
|
///
|
|
/// # Errors
|
|
/// Returns an error if the queue is empty or if any backend operation fails.
|
|
pub fn shuffle_queue(&self) -> Result<(), ControlPointError> {
|
|
use rand::seq::SliceRandom;
|
|
use rand::thread_rng;
|
|
|
|
// 1. Clear the playlist binding (detach from playlist)
|
|
self.clear_playlist_binding();
|
|
|
|
// 2. Stop playback (ignore errors if already stopped)
|
|
let _ = self.stop();
|
|
|
|
// 3. Get a snapshot of the current queue
|
|
let snapshot = self.queue_snapshot()?;
|
|
|
|
if snapshot.items.is_empty() {
|
|
return Err(ControlPointError::QueueError(
|
|
"Cannot shuffle an empty queue".to_string(),
|
|
));
|
|
}
|
|
|
|
// 4. Shuffle the items
|
|
let mut shuffled_items = snapshot.items;
|
|
let mut rng = thread_rng();
|
|
shuffled_items.shuffle(&mut rng);
|
|
|
|
// 5. Replace the queue with shuffled items, starting at index 0
|
|
self.replace_queue(shuffled_items, Some(0))?;
|
|
|
|
// 6. Start playback from the first track with retry for JBL-like renderers
|
|
// that fail the first Play command due to timing issues
|
|
self.play_current_from_queue_with_retry()?;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Helper function to build DIDL-Lite metadata XML from TrackMetadata
|
|
pub(crate) fn build_didl_lite_metadata(
|
|
metadata: &TrackMetadata,
|
|
uri: &str,
|
|
protocol_info: &str,
|
|
) -> String {
|
|
use pmodidl::ToXmlElement;
|
|
use pmodidl::{DIDLLite, Item, Resource};
|
|
|
|
// Construire l'Item DIDL avec toutes les métadonnées
|
|
let item = Item {
|
|
id: "0".to_string(),
|
|
parent_id: "-1".to_string(),
|
|
restricted: Some("1".to_string()),
|
|
title: metadata
|
|
.title
|
|
.clone()
|
|
.unwrap_or_else(|| "Unknown Title".to_string()),
|
|
creator: metadata.creator.clone().or_else(|| metadata.artist.clone()),
|
|
class: "object.item.audioItem.musicTrack".to_string(),
|
|
artist: metadata.artist.clone(),
|
|
album: metadata.album.clone(),
|
|
genre: metadata.genre.clone(),
|
|
album_art: metadata.album_art_uri.clone(),
|
|
album_art_pk: None,
|
|
date: metadata.date.clone(),
|
|
original_track_number: metadata.track_number.clone(),
|
|
resources: vec![Resource {
|
|
protocol_info: protocol_info.to_string(),
|
|
bits_per_sample: None,
|
|
sample_frequency: None,
|
|
nr_audio_channels: None,
|
|
duration: metadata.duration.clone(),
|
|
url: uri.to_string(),
|
|
}],
|
|
descriptions: vec![],
|
|
};
|
|
|
|
// Construire le DIDL-Lite complet
|
|
let didl = DIDLLite {
|
|
items: vec![item],
|
|
..Default::default()
|
|
};
|
|
|
|
// Sérialiser en XML via pmodidl
|
|
didl.to_xml()
|
|
}
|
|
|
|
impl DeviceIdentity for MusicRenderer {
|
|
fn id(&self) -> DeviceId {
|
|
self.info.id()
|
|
}
|
|
|
|
fn udn(&self) -> &str {
|
|
&*self.info.udn()
|
|
}
|
|
|
|
fn friendly_name(&self) -> &str {
|
|
&self.info.friendly_name()
|
|
}
|
|
|
|
fn model_name(&self) -> &str {
|
|
&self.info.model_name()
|
|
}
|
|
fn manufacturer(&self) -> &str {
|
|
&self.info.manufacturer()
|
|
}
|
|
fn location(&self) -> &str {
|
|
&self.info.location()
|
|
}
|
|
fn server_header(&self) -> &str {
|
|
&self.info.server_header()
|
|
}
|
|
}
|
|
|
|
impl DeviceOnline for MusicRenderer {
|
|
fn is_online(&self) -> bool {
|
|
self.connection
|
|
.lock()
|
|
.expect("Connection mutex poisoned")
|
|
.is_online()
|
|
}
|
|
|
|
fn last_seen(&self) -> SystemTime {
|
|
self.connection
|
|
.lock()
|
|
.expect("Connection mutex poisoned")
|
|
.last_seen()
|
|
}
|
|
|
|
fn has_been_seen_now(&self, max_age: u32) {
|
|
let was_online = self.is_online();
|
|
self.connection
|
|
.lock()
|
|
.expect("Connection mutex poisoned")
|
|
.has_been_seen_now(max_age);
|
|
|
|
// Start watching if transitioning from offline to online
|
|
if !was_online {
|
|
self.start_watching();
|
|
}
|
|
}
|
|
|
|
fn mark_as_offline(&self) {
|
|
// Stop watching before marking offline
|
|
self.stop_watching();
|
|
self.connection
|
|
.lock()
|
|
.expect("Connection mutex poisoned")
|
|
.mark_as_offline()
|
|
}
|
|
|
|
fn max_age(&self) -> u32 {
|
|
self.connection
|
|
.lock()
|
|
.expect("Connection mutex poisoned")
|
|
.max_age()
|
|
}
|
|
}
|
|
|
|
impl RendererFromMediaRendererInfo for MusicRendererBackend {
|
|
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
|
// Try OpenHome first for OpenHome-capable renderers
|
|
match info.protocol() {
|
|
RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid => {
|
|
if let Ok(backend) = OpenHomeRenderer::build_from_renderer_info(info) {
|
|
return Ok(backend);
|
|
}
|
|
// If OpenHomeOnly failed, it's an error
|
|
if matches!(info.protocol(), RendererProtocol::OpenHomeOnly) {
|
|
return Err(ControlPointError::MusicRendererBackendBuild(format!(
|
|
"OpenHomeOnly renderer {} has no usable OpenHome services",
|
|
info.friendly_name()
|
|
)));
|
|
}
|
|
// OpenHomeHybrid: fall through to try UPnP-based backends
|
|
}
|
|
RendererProtocol::ChromecastOnly => {
|
|
return ChromecastRenderer::build_from_renderer_info(info);
|
|
}
|
|
RendererProtocol::UpnpAvOnly => {
|
|
// Will be handled below
|
|
}
|
|
}
|
|
|
|
// UPnP-based renderers (UpnpAvOnly or OpenHomeHybrid fallback)
|
|
let has_arylic = info.capabilities().has_arylic_tcp;
|
|
let has_avtransport = info.capabilities().has_avtransport();
|
|
|
|
// Try Hybrid UPnP + Arylic (special case: combines two backends)
|
|
if has_arylic && has_avtransport {
|
|
let upnp_backend = UpnpRenderer::build_from_renderer_info(info)?;
|
|
if let MusicRendererBackend::Upnp(upnp) = upnp_backend {
|
|
// Share the UPnP queue with Arylic so metadata is consistent
|
|
let shared_queue = upnp.queue().clone();
|
|
match ArylicTcpRenderer::with_shared_queue(info, shared_queue) {
|
|
Ok(arylic) => {
|
|
return Ok(MusicRendererBackend::HybridUpnpArylic { upnp, arylic });
|
|
}
|
|
Err(err) => {
|
|
warn!(
|
|
renderer = info.friendly_name(),
|
|
error = %err,
|
|
"Failed to build Arylic TCP backend, falling back to UPnP only"
|
|
);
|
|
return Ok(MusicRendererBackend::Upnp(upnp));
|
|
}
|
|
_ => unreachable!(
|
|
"ArylicTcpRenderer::build_from_renderer_info should return ArylicTcp variant"
|
|
),
|
|
}
|
|
} else {
|
|
unreachable!("UpnpRenderer::build_from_renderer_info should return Upnp variant");
|
|
}
|
|
}
|
|
|
|
// Try LinkPlay
|
|
if info.capabilities().has_linkplay_http() {
|
|
if let Ok(backend) = LinkPlayRenderer::build_from_renderer_info(info) {
|
|
return Ok(backend);
|
|
}
|
|
}
|
|
|
|
// Fallback to UPnP
|
|
if has_avtransport {
|
|
return UpnpRenderer::build_from_renderer_info(info);
|
|
}
|
|
|
|
// No suitable backend found
|
|
Err(ControlPointError::MusicRendererBackendBuild(format!(
|
|
"No suitable backend for renderer {}",
|
|
info.friendly_name()
|
|
)))
|
|
}
|
|
|
|
fn to_backend(self) -> MusicRendererBackend {
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Parse duration from DIDL-Lite metadata XML.
|
|
///
|
|
/// Extracts the duration attribute from the <res> element in DIDL metadata.
|
|
/// This is used as a fallback when the renderer doesn't provide track_duration
|
|
/// in GetPositionInfo or similar calls.
|
|
fn parse_didl_duration(didl_xml: &str) -> Option<String> {
|
|
// Parse DIDL-Lite XML properly using pmodidl
|
|
let didl = match DIDLLite::parse(didl_xml) {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
tracing::trace!("Failed to parse DIDL metadata: {}", e);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
// Extract duration from the first item's first resource
|
|
let duration = didl.items.first()?.resources.first()?.duration.clone();
|
|
|
|
if let Some(ref dur) = duration {
|
|
tracing::debug!(
|
|
"MusicRenderer: Extracted duration from DIDL metadata: {}",
|
|
dur
|
|
);
|
|
} else {
|
|
tracing::trace!("No duration attribute found in DIDL metadata");
|
|
}
|
|
|
|
duration
|
|
}
|
|
|
|
/// Parse un datetime RFC 3339 en SystemTime.
|
|
///
|
|
/// Utilisé pour calibrer track_start_time sur le début réel de diffusion
|
|
/// d'un segment, encodé dans dc:date du DIDL par la source stream.
|
|
fn parse_rfc3339_to_system_time(s: &str) -> Option<SystemTime> {
|
|
let dt = chrono::DateTime::parse_from_rfc3339(s).ok()?;
|
|
let secs = dt.timestamp();
|
|
if secs < 0 {
|
|
return None;
|
|
}
|
|
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs as u64))
|
|
}
|
|
|
|
/// Transport control façade that dispatches to whichever backend can fulfill
|
|
/// the request, returning a standardized error if the backend lacks support.
|
|
impl TransportControl for MusicRendererBackend {
|
|
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::Upnp(upnp) => upnp.play_uri(uri, meta),
|
|
MusicRendererBackend::OpenHome(oh) => oh.play_uri(uri, meta),
|
|
MusicRendererBackend::LinkPlay(lp) => lp.play_uri(uri, meta),
|
|
MusicRendererBackend::ArylicTcp(_) => Err(
|
|
ControlPointError::upnp_operation_not_supported("play_uri", "ArylicTcp"),
|
|
),
|
|
MusicRendererBackend::Chromecast(cc) => cc.play_uri(uri, meta),
|
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta),
|
|
}
|
|
}
|
|
|
|
fn play(&self) -> Result<(), ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::Upnp(upnp) => upnp.play(),
|
|
MusicRendererBackend::OpenHome(oh) => oh.play(),
|
|
MusicRendererBackend::LinkPlay(lp) => lp.play(),
|
|
MusicRendererBackend::ArylicTcp(ary) => ary.play(),
|
|
MusicRendererBackend::Chromecast(cc) => cc.play(),
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.play(),
|
|
}
|
|
}
|
|
|
|
fn pause(&self) -> Result<(), ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::Upnp(upnp) => upnp.pause(),
|
|
MusicRendererBackend::OpenHome(oh) => oh.pause(),
|
|
MusicRendererBackend::LinkPlay(lp) => lp.pause(),
|
|
MusicRendererBackend::ArylicTcp(ary) => ary.pause(),
|
|
MusicRendererBackend::Chromecast(cc) => cc.pause(),
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.pause(),
|
|
}
|
|
}
|
|
|
|
fn stop(&self) -> Result<(), ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::Upnp(upnp) => upnp.stop(),
|
|
MusicRendererBackend::OpenHome(oh) => oh.stop(),
|
|
MusicRendererBackend::LinkPlay(lp) => lp.stop(),
|
|
MusicRendererBackend::ArylicTcp(ary) => ary.stop(),
|
|
MusicRendererBackend::Chromecast(cc) => cc.stop(),
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.stop(),
|
|
}
|
|
}
|
|
|
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::Upnp(upnp) => upnp.seek_rel_time(hhmmss),
|
|
MusicRendererBackend::OpenHome(oh) => oh.seek_rel_time(hhmmss),
|
|
MusicRendererBackend::LinkPlay(lp) => lp.seek_rel_time(hhmmss),
|
|
MusicRendererBackend::ArylicTcp(_) => Err(
|
|
ControlPointError::upnp_operation_not_supported("seek_rel_time", "ArylicTcp"),
|
|
),
|
|
MusicRendererBackend::Chromecast(cc) => cc.seek_rel_time(hhmmss),
|
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Volume and mute controls exposed via the façade.
|
|
///
|
|
/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers
|
|
/// always depend on a single [`VolumeControl`] entry point.
|
|
impl VolumeControl for MusicRendererBackend {
|
|
fn volume(&self) -> Result<u16, ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.volume(),
|
|
MusicRendererBackend::ArylicTcp(ary) => ary.volume(),
|
|
MusicRendererBackend::OpenHome(oh) => oh.volume(),
|
|
MusicRendererBackend::Upnp(upnp) => upnp.volume(),
|
|
MusicRendererBackend::LinkPlay(lp) => lp.volume(),
|
|
MusicRendererBackend::Chromecast(cc) => cc.volume(),
|
|
}
|
|
}
|
|
|
|
fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol),
|
|
MusicRendererBackend::ArylicTcp(ary) => ary.set_volume(vol),
|
|
MusicRendererBackend::OpenHome(oh) => oh.set_volume(vol),
|
|
MusicRendererBackend::Upnp(upnp) => upnp.set_volume(vol),
|
|
MusicRendererBackend::LinkPlay(lp) => lp.set_volume(vol),
|
|
MusicRendererBackend::Chromecast(cc) => cc.set_volume(vol),
|
|
}
|
|
}
|
|
|
|
fn mute(&self) -> Result<bool, ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.mute(),
|
|
MusicRendererBackend::OpenHome(r) => r.mute(),
|
|
MusicRendererBackend::Upnp(r) => r.mute(),
|
|
MusicRendererBackend::LinkPlay(r) => r.mute(),
|
|
MusicRendererBackend::ArylicTcp(r) => r.mute(),
|
|
MusicRendererBackend::Chromecast(cc) => cc.mute(),
|
|
}
|
|
}
|
|
|
|
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m),
|
|
MusicRendererBackend::OpenHome(r) => r.set_mute(m),
|
|
MusicRendererBackend::Upnp(r) => r.set_mute(m),
|
|
MusicRendererBackend::LinkPlay(r) => r.set_mute(m),
|
|
MusicRendererBackend::ArylicTcp(r) => r.set_mute(m),
|
|
MusicRendererBackend::Chromecast(cc) => cc.set_mute(m),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Playback-state queries sourced from the backend best suited for the job.
|
|
///
|
|
/// Each backend reports into [`PlaybackState`], ensuring consumers never have
|
|
/// to reason about protocol-specific state machines.
|
|
impl PlaybackStatus for MusicRendererBackend {
|
|
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::Upnp(r) => PlaybackStatus::playback_state(r),
|
|
MusicRendererBackend::OpenHome(r) => PlaybackStatus::playback_state(r),
|
|
MusicRendererBackend::LinkPlay(r) => r.playback_state(),
|
|
MusicRendererBackend::ArylicTcp(r) => r.playback_state(),
|
|
MusicRendererBackend::Chromecast(cc) => cc.playback_state(),
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_state(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Playback-position queries that always yield a [`PlaybackPositionInfo`]
|
|
/// regardless of the backend providing the raw transport data.
|
|
impl PlaybackPosition for MusicRendererBackend {
|
|
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::Upnp(r) => r.playback_position(),
|
|
MusicRendererBackend::OpenHome(r) => r.playback_position(),
|
|
MusicRendererBackend::LinkPlay(r) => r.playback_position(),
|
|
MusicRendererBackend::ArylicTcp(r) => r.playback_position(),
|
|
MusicRendererBackend::Chromecast(cc) => cc.playback_position(),
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_position(),
|
|
}
|
|
}
|
|
}
|
|
|
|
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>,
|
|
cancel_token: &Arc<AtomicBool>,
|
|
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
|
) -> Result<(), ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::Upnp(r) => r.sync_queue(items, cancel_token, on_ready),
|
|
MusicRendererBackend::OpenHome(r) => r.sync_queue(items, cancel_token, on_ready),
|
|
MusicRendererBackend::LinkPlay(r) => r.sync_queue(items, cancel_token, on_ready),
|
|
MusicRendererBackend::ArylicTcp(r) => r.sync_queue(items, cancel_token, on_ready),
|
|
MusicRendererBackend::Chromecast(cc) => cc.sync_queue(items, cancel_token, on_ready),
|
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => {
|
|
upnp.sync_queue(items, cancel_token, on_ready)
|
|
}
|
|
}
|
|
}
|
|
|
|
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),
|
|
}
|
|
}
|
|
}
|