a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv
chromecast
mpris
navidrome
jellyfin
upnp
tui
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}