a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv chromecast mpris navidrome jellyfin upnp tui
0

Configure Feed

Select the types of activity you want to include in your feed.

fin / crates / fin-player / src / local.rs
14 kB 409 lines
1//! Local renderer — routes each item to the right backend: 2//! audio → rockbox-playback (never mpv), video → mpv. 3//! 4//! To the caller this looks like a single `Renderer`. Internally we keep a 5//! merged queue view so pause/next/prev/etc. hit whichever backend is 6//! currently sourcing sound to the speakers or pixels to the screen. 7 8use std::path::PathBuf; 9use std::sync::Arc; 10 11use anyhow::Result; 12use async_trait::async_trait; 13use parking_lot::Mutex; 14use tracing::debug; 15 16use fin_config::EqBand; 17 18use crate::crossfade::CrossfadeSettings; 19use crate::mpv::MpvRenderer; 20use crate::persist::PersistedQueue; 21use crate::queue::{QueueItem, RepeatMode}; 22use crate::renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind}; 23use crate::replaygain::ReplayGainSettings; 24use crate::rockbox_player::RockboxPlayer; 25 26/// Which backend is currently sourcing playback. 27#[derive(Debug, Clone, Copy, PartialEq, Eq)] 28enum Active { 29 None, 30 Audio, 31 Video, 32} 33 34pub struct LocalRenderer { 35 audio: Arc<RockboxPlayer>, 36 video: Arc<MpvRenderer>, 37 active: Arc<Mutex<Active>>, 38} 39 40impl LocalRenderer { 41 pub fn new() -> Self { 42 Self::with_persist(None) 43 } 44 45 /// Build a LocalRenderer whose RockboxPlayer persists its queue to 46 /// `queue_path`. mpv's video queue is transient by design — a video 47 /// process spawns per session and doesn't persist. 48 pub fn with_persist(queue_path: Option<PathBuf>) -> Self { 49 Self { 50 audio: Arc::new(RockboxPlayer::with_persist(queue_path)), 51 video: Arc::new(MpvRenderer::new(None)), 52 active: Arc::new(Mutex::new(Active::None)), 53 } 54 } 55 56 fn set_active(&self, a: Active) { 57 *self.active.lock() = a; 58 } 59 60 fn get_active(&self) -> Active { 61 *self.active.lock() 62 } 63 64 /// Split a heterogeneous queue by media kind. The current API doesn't let 65 /// us interleave audio and video across two backends, so when a queue 66 /// contains both we play the first item's kind and hand the same list to 67 /// that backend — filtering out mismatched items. In practice a queue is 68 /// almost always uniform (all audio from an album, or a single video). 69 fn dispatch_target(items: &[QueueItem], start_index: usize) -> Active { 70 items 71 .get(start_index) 72 .map(|i| { 73 if i.is_video { 74 Active::Video 75 } else { 76 Active::Audio 77 } 78 }) 79 .unwrap_or(Active::None) 80 } 81 82 async fn stop_other(&self, keep: Active) { 83 // Only the currently-active backend has a running stream/process, so 84 // we only stop the opposite one to avoid churn. 85 match (self.get_active(), keep) { 86 (Active::Audio, Active::Video) => { 87 let _ = self.audio.stop().await; 88 } 89 (Active::Video, Active::Audio) => { 90 let _ = self.video.stop().await; 91 } 92 _ => {} 93 } 94 } 95} 96 97impl Default for LocalRenderer { 98 fn default() -> Self { 99 Self::new() 100 } 101} 102 103fn filter_kind(items: Vec<QueueItem>, want_video: bool) -> Vec<QueueItem> { 104 items 105 .into_iter() 106 .filter(|i| i.is_video == want_video) 107 .collect() 108} 109 110#[async_trait] 111impl Renderer for LocalRenderer { 112 fn kind(&self) -> RendererKind { 113 RendererKind::Mpv 114 } 115 116 async fn play(&self, items: Vec<QueueItem>, start_index: usize) -> Result<()> { 117 let target = Self::dispatch_target(&items, start_index); 118 self.stop_other(target).await; 119 match target { 120 Active::Audio => { 121 let filtered = filter_kind(items, false); 122 let idx = start_index.min(filtered.len().saturating_sub(1)); 123 debug!( 124 items = filtered.len(), 125 "local: routing audio → rockbox-playback" 126 ); 127 self.set_active(Active::Audio); 128 self.audio.play(filtered, idx).await 129 } 130 Active::Video => { 131 let filtered = filter_kind(items, true); 132 let idx = start_index.min(filtered.len().saturating_sub(1)); 133 debug!(items = filtered.len(), "local: routing video → mpv"); 134 self.set_active(Active::Video); 135 self.video.play(filtered, idx).await 136 } 137 Active::None => Ok(()), 138 } 139 } 140 141 async fn enqueue(&self, items: Vec<QueueItem>) -> Result<()> { 142 // Split items and enqueue on whichever backend matches. If nothing is 143 // playing yet, kick off playback on whichever kind we saw first. 144 let (audio_items, video_items): (Vec<_>, Vec<_>) = 145 items.into_iter().partition(|i| !i.is_video); 146 let active = self.get_active(); 147 match active { 148 Active::Audio => { 149 if !audio_items.is_empty() { 150 self.audio.enqueue(audio_items).await?; 151 } 152 if !video_items.is_empty() { 153 debug!( 154 "dropping {} video item(s) — audio already active", 155 video_items.len() 156 ); 157 } 158 Ok(()) 159 } 160 Active::Video => { 161 if !video_items.is_empty() { 162 self.video.enqueue(video_items).await?; 163 } 164 if !audio_items.is_empty() { 165 debug!( 166 "dropping {} audio item(s) — video already active", 167 audio_items.len() 168 ); 169 } 170 Ok(()) 171 } 172 Active::None => { 173 if !audio_items.is_empty() { 174 self.set_active(Active::Audio); 175 self.audio.play(audio_items, 0).await 176 } else if !video_items.is_empty() { 177 self.set_active(Active::Video); 178 self.video.play(video_items, 0).await 179 } else { 180 Ok(()) 181 } 182 } 183 } 184 } 185 186 async fn play_next(&self, items: Vec<QueueItem>) -> Result<()> { 187 let (audio_items, video_items): (Vec<_>, Vec<_>) = 188 items.into_iter().partition(|i| !i.is_video); 189 match self.get_active() { 190 Active::Audio => { 191 if !audio_items.is_empty() { 192 self.audio.play_next(audio_items).await?; 193 } 194 Ok(()) 195 } 196 Active::Video => { 197 if !video_items.is_empty() { 198 self.video.play_next(video_items).await?; 199 } 200 Ok(()) 201 } 202 Active::None => { 203 if !audio_items.is_empty() { 204 self.set_active(Active::Audio); 205 self.audio.play(audio_items, 0).await 206 } else if !video_items.is_empty() { 207 self.set_active(Active::Video); 208 self.video.play(video_items, 0).await 209 } else { 210 Ok(()) 211 } 212 } 213 } 214 } 215 216 async fn pause(&self) -> Result<()> { 217 match self.get_active() { 218 Active::Audio => self.audio.pause().await, 219 Active::Video => self.video.pause().await, 220 Active::None => Ok(()), 221 } 222 } 223 224 async fn resume(&self) -> Result<()> { 225 match self.get_active() { 226 Active::Audio => self.audio.resume().await, 227 Active::Video => self.video.resume().await, 228 Active::None => Ok(()), 229 } 230 } 231 232 async fn stop(&self) -> Result<()> { 233 let a = self.get_active(); 234 self.set_active(Active::None); 235 match a { 236 Active::Audio => self.audio.stop().await, 237 Active::Video => self.video.stop().await, 238 Active::None => Ok(()), 239 } 240 } 241 242 async fn next(&self) -> Result<()> { 243 match self.get_active() { 244 Active::Audio => self.audio.next().await, 245 Active::Video => self.video.next().await, 246 Active::None => Ok(()), 247 } 248 } 249 250 async fn previous(&self) -> Result<()> { 251 match self.get_active() { 252 Active::Audio => self.audio.previous().await, 253 Active::Video => self.video.previous().await, 254 Active::None => Ok(()), 255 } 256 } 257 258 async fn seek(&self, position_secs: f64) -> Result<()> { 259 match self.get_active() { 260 Active::Audio => self.audio.seek(position_secs).await, 261 Active::Video => self.video.seek(position_secs).await, 262 Active::None => Ok(()), 263 } 264 } 265 266 async fn set_volume(&self, volume: f32) -> Result<()> { 267 // Volume changes on both — the user's expectation is a single global slider. 268 let _ = self.audio.set_volume(volume).await; 269 let _ = self.video.set_volume(volume).await; 270 Ok(()) 271 } 272 273 async fn set_shuffle(&self, on: bool) -> Result<()> { 274 // Shuffle currently only affects the audio queue. mpv's video queue 275 // handles this differently and we haven't wired shuffle in there. 276 self.audio.set_shuffle(on).await 277 } 278 279 async fn set_repeat(&self, mode: RepeatMode) -> Result<()> { 280 self.audio.set_repeat(mode).await 281 } 282 283 async fn restore(&self, snapshot: PersistedQueue) -> Result<()> { 284 // Restore always goes to the audio path — that's where persistence 285 // lives. If the snapshot has video items they're dropped by the 286 // RockboxPlayer's restore handler. 287 self.set_active(Active::Audio); 288 self.audio.restore(snapshot).await 289 } 290 291 async fn remove_from_queue(&self, index: usize) -> Result<()> { 292 match self.get_active() { 293 Active::Audio => self.audio.remove_from_queue(index).await, 294 Active::Video => self.video.remove_from_queue(index).await, 295 Active::None => Ok(()), 296 } 297 } 298 299 async fn set_replaygain(&self, settings: ReplayGainSettings) -> Result<()> { 300 // ReplayGain is applied in the audio decode path — mpv has its own 301 // separate volume model for video that we don't touch here. 302 self.audio.set_replaygain(settings).await 303 } 304 305 async fn set_crossfade(&self, settings: CrossfadeSettings) -> Result<()> { 306 // Only audio-side has the dual-track crossfade wiring. 307 self.audio.set_crossfade(settings).await 308 } 309 310 async fn set_eq(&self, enabled: bool, bands: Vec<EqBand>) -> Result<()> { 311 // EQ runs in the audio decode path — mpv-driven video is not affected. 312 self.audio.set_eq(enabled, bands).await 313 } 314 315 async fn set_tone( 316 &self, 317 bass_db: i32, 318 treble_db: i32, 319 bass_cutoff_hz: i32, 320 treble_cutoff_hz: i32, 321 ) -> Result<()> { 322 self.audio 323 .set_tone(bass_db, treble_db, bass_cutoff_hz, treble_cutoff_hz) 324 .await 325 } 326 327 fn state(&self) -> PlaybackState { 328 match self.get_active() { 329 Active::Audio => self.audio.state(), 330 Active::Video => self.video.state(), 331 // Idle still reports the audio player's volume — it survives 332 // across stop/start, and volume queries (TUI slider, UPnP 333 // GetVolume) shouldn't snap back to 100% just because nothing 334 // is playing. 335 Active::None => PlaybackState { 336 status: PlaybackStatus::Idle, 337 volume: self.audio.state().volume, 338 ..Default::default() 339 }, 340 } 341 } 342} 343 344#[cfg(test)] 345mod tests { 346 use super::*; 347 348 fn audio(id: &str) -> QueueItem { 349 QueueItem { 350 id: id.into(), 351 title: id.into(), 352 subtitle: String::new(), 353 stream_url: format!("http://example/{id}.flac"), 354 image_url: None, 355 duration_secs: Some(120), 356 is_video: false, 357 content_type: "audio/flac".into(), 358 } 359 } 360 361 fn video(id: &str) -> QueueItem { 362 QueueItem { 363 id: id.into(), 364 title: id.into(), 365 subtitle: String::new(), 366 stream_url: format!("http://example/{id}.mp4"), 367 image_url: None, 368 duration_secs: Some(600), 369 is_video: true, 370 content_type: "video/mp4".into(), 371 } 372 } 373 374 #[test] 375 fn dispatch_picks_kind_of_item_at_start_index() { 376 let items = vec![audio("a"), video("b"), audio("c")]; 377 assert_eq!(LocalRenderer::dispatch_target(&items, 0), Active::Audio); 378 assert_eq!(LocalRenderer::dispatch_target(&items, 1), Active::Video); 379 assert_eq!(LocalRenderer::dispatch_target(&items, 2), Active::Audio); 380 } 381 382 #[test] 383 fn dispatch_on_empty_or_out_of_range_returns_none() { 384 assert_eq!(LocalRenderer::dispatch_target(&[], 0), Active::None); 385 let items = vec![audio("a")]; 386 assert_eq!(LocalRenderer::dispatch_target(&items, 5), Active::None); 387 } 388 389 #[test] 390 fn filter_kind_keeps_only_matching_items() { 391 let mixed = vec![audio("a"), video("b"), audio("c"), video("d")]; 392 let audio_only = filter_kind(mixed.clone(), false); 393 assert_eq!( 394 audio_only.iter().map(|i| i.id.clone()).collect::<Vec<_>>(), 395 vec!["a", "c"] 396 ); 397 let video_only = filter_kind(mixed, true); 398 assert_eq!( 399 video_only.iter().map(|i| i.id.clone()).collect::<Vec<_>>(), 400 vec!["b", "d"] 401 ); 402 } 403 404 #[test] 405 fn filter_kind_on_empty_returns_empty() { 406 assert!(filter_kind(vec![], false).is_empty()); 407 assert!(filter_kind(vec![], true).is_empty()); 408 } 409}