1use serde::{Deserialize, Serialize};
4
5#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
7#[serde(rename_all = "lowercase")]
8pub enum VolumeLevel {
9 Loud,
11 #[default]
13 Normal,
14 Quiet,
16}
17
18impl VolumeLevel {
19 pub fn target_lufs(&self) -> f32 {
21 match self {
22 VolumeLevel::Loud => -11.0,
23 VolumeLevel::Normal => -14.0,
24 VolumeLevel::Quiet => -23.0,
25 }
26 }
27}
28
29pub const EQ_BANDS: [f32; 10] = [
37 31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
38];
39pub const EQ_GAIN_MIN: f32 = -12.0;
41pub const EQ_GAIN_MAX: f32 = 12.0;
43
44#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub enum EqPreset {
52 Flat,
53 Rock,
54 Pop,
55 Jazz,
56 Classical,
57 BassBoost,
58 TrebleBoost,
59 Vocal,
60}
61
62impl EqPreset {
63 pub const ALL: [EqPreset; 8] = [
65 EqPreset::Flat,
66 EqPreset::Rock,
67 EqPreset::Pop,
68 EqPreset::Jazz,
69 EqPreset::Classical,
70 EqPreset::BassBoost,
71 EqPreset::TrebleBoost,
72 EqPreset::Vocal,
73 ];
74
75 pub fn gains(&self) -> [f32; 10] {
79 match self {
80 EqPreset::Flat => [0.0; 10],
81 EqPreset::Rock => [5.0, 4.0, 3.0, 1.0, -1.0, -1.0, 1.0, 3.0, 4.0, 5.0],
82 EqPreset::Pop => [-1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0, -1.0],
83 EqPreset::Jazz => [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.0, 2.0, 3.0],
84 EqPreset::Classical => [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0],
85 EqPreset::BassBoost => [7.0, 6.0, 5.0, 3.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
86 EqPreset::TrebleBoost => [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 3.0, 5.0, 6.0, 7.0],
87 EqPreset::Vocal => [-2.0, -1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0],
88 }
89 }
90}
91
92#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct AudioSettings {
96 pub crossfade_duration: f32,
98 pub gapless_playback: bool,
100 pub normalize_volume: bool,
102 pub volume_level: VolumeLevel,
104 #[serde(default)]
106 pub equalizer_enabled: bool,
107 #[serde(default = "default_eq_bands")]
110 pub equalizer_bands: Vec<f32>,
111}
112
113fn default_eq_bands() -> Vec<f32> {
115 vec![0.0; EQ_BANDS.len()]
116}
117
118impl Default for AudioSettings {
119 fn default() -> Self {
120 Self {
121 crossfade_duration: 0.0,
122 gapless_playback: true,
123 normalize_volume: false,
124 volume_level: VolumeLevel::Normal,
125 equalizer_enabled: false,
126 equalizer_bands: default_eq_bands(),
127 }
128 }
129}
130
131impl AudioSettings {
132 pub fn with_crossfade_clamped(mut self) -> Self {
134 self.crossfade_duration = self.crossfade_duration.clamp(0.0, 12.0);
135 self
136 }
137
138 pub fn with_equalizer_normalised(mut self) -> Self {
144 let n = EQ_BANDS.len();
145 self.equalizer_bands.resize(n, 0.0);
146 for g in &mut self.equalizer_bands {
147 *g = g.clamp(EQ_GAIN_MIN, EQ_GAIN_MAX);
148 }
149 self
150 }
151}
152
153#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
169#[serde(rename_all = "camelCase")]
170pub enum StreamingQuality {
171 #[default]
173 Original,
174 Mbps20,
175 Mbps10,
176 Mbps8,
177 Mbps4,
178 Mbps2,
179 Mbps1,
180 Kbps720,
181}
182
183impl StreamingQuality {
184 pub const ALL: [StreamingQuality; 8] = [
186 StreamingQuality::Original,
187 StreamingQuality::Mbps20,
188 StreamingQuality::Mbps10,
189 StreamingQuality::Mbps8,
190 StreamingQuality::Mbps4,
191 StreamingQuality::Mbps2,
192 StreamingQuality::Mbps1,
193 StreamingQuality::Kbps720,
194 ];
195
196 pub fn max_bitrate(&self) -> Option<u64> {
205 match self {
206 StreamingQuality::Original => None,
207 StreamingQuality::Mbps20 => Some(20_000_000),
208 StreamingQuality::Mbps10 => Some(10_000_000),
209 StreamingQuality::Mbps8 => Some(8_000_000),
210 StreamingQuality::Mbps4 => Some(4_000_000),
211 StreamingQuality::Mbps2 => Some(2_000_000),
212 StreamingQuality::Mbps1 => Some(1_000_000),
213 StreamingQuality::Kbps720 => Some(720_000),
214 }
215 }
216
217 pub fn audio_bitrate(&self) -> u64 {
222 match self {
223 StreamingQuality::Original
224 | StreamingQuality::Mbps20
225 | StreamingQuality::Mbps10
226 | StreamingQuality::Mbps8 => 384_000,
227 StreamingQuality::Mbps4 => 256_000,
228 StreamingQuality::Mbps2 => 192_000,
229 StreamingQuality::Mbps1 => 128_000,
230 StreamingQuality::Kbps720 => 96_000,
231 }
232 }
233
234 pub fn video_bitrate(&self) -> Option<u64> {
238 self.max_bitrate()
239 .map(|total| total.saturating_sub(self.audio_bitrate()))
240 }
241
242 pub fn max_height(&self) -> Option<u32> {
246 match self {
247 StreamingQuality::Original | StreamingQuality::Mbps20 => None,
249 StreamingQuality::Mbps10 | StreamingQuality::Mbps8 => Some(1080),
250 StreamingQuality::Mbps4 | StreamingQuality::Mbps2 => Some(720),
251 StreamingQuality::Mbps1 => Some(480),
252 StreamingQuality::Kbps720 => Some(360),
253 }
254 }
255
256 pub fn label(&self) -> &'static str {
259 match self {
260 StreamingQuality::Original => "Original",
261 StreamingQuality::Mbps20 => "20 Mbps",
262 StreamingQuality::Mbps10 => "10 Mbps",
263 StreamingQuality::Mbps8 => "8 Mbps",
264 StreamingQuality::Mbps4 => "4 Mbps",
265 StreamingQuality::Mbps2 => "2 Mbps",
266 StreamingQuality::Mbps1 => "1 Mbps",
267 StreamingQuality::Kbps720 => "720 kbps",
268 }
269 }
270
271 pub fn detail(&self) -> &'static str {
273 match self {
274 StreamingQuality::Original => "No limit — highest quality",
275 StreamingQuality::Mbps20 => "Up to 4K",
276 StreamingQuality::Mbps10 => "1080p, high quality",
277 StreamingQuality::Mbps8 => "1080p",
278 StreamingQuality::Mbps4 => "720p",
279 StreamingQuality::Mbps2 => "720p, reduced",
280 StreamingQuality::Mbps1 => "480p",
281 StreamingQuality::Kbps720 => "360p — slowest connections",
282 }
283 }
284}
285
286#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
288#[serde(rename_all = "camelCase")]
289pub struct VideoSettings {
290 pub auto_play_next_episode: bool,
292 pub auto_play_countdown_seconds: u32,
294 #[serde(default)]
296 pub auto_play_max_episodes: u32,
297 #[serde(default)]
304 pub streaming_quality: StreamingQuality,
305}
306
307impl Default for VideoSettings {
308 fn default() -> Self {
309 Self {
310 auto_play_next_episode: true,
311 auto_play_countdown_seconds: 10,
312 auto_play_max_episodes: 0,
313 streaming_quality: StreamingQuality::Original,
314 }
315 }
316}
317
318impl VideoSettings {
319 pub fn with_countdown_clamped(mut self) -> Self {
321 self.auto_play_countdown_seconds = self.auto_play_countdown_seconds.clamp(5, 30);
322 self
323 }
324}
325
326#[derive(specta::Type, Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "camelCase")]
340pub struct LibrarySettings {
341 #[serde(default)]
346 pub excluded_item_ids: Vec<String>,
347}
348
349impl LibrarySettings {
350 pub fn sanitised(mut self) -> Self {
359 let mut seen: Vec<String> = Vec::with_capacity(self.excluded_item_ids.len());
360 for id in self.excluded_item_ids.drain(..) {
361 let id = id.trim().to_string();
362 if id.is_empty() || seen.contains(&id) {
363 continue;
364 }
365 seen.push(id);
366 }
367 self.excluded_item_ids = seen;
368 self
369 }
370}
371
372pub fn audio_settings_jni_payload(settings: &AudioSettings) -> Result<String, serde_json::Error> {
385 let sanitised = settings
386 .clone()
387 .with_crossfade_clamped()
388 .with_equalizer_normalised();
389 serde_json::to_string(&sanitised)
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
402 fn test_audio_settings_jni_payload_is_sanitised() {
403 let settings = AudioSettings {
404 crossfade_duration: 30.0,
405 equalizer_bands: vec![20.0, -30.0],
406 ..AudioSettings::default()
407 };
408
409 let json = audio_settings_jni_payload(&settings).expect("serialises");
410 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
411
412 assert_eq!(v["crossfadeDuration"], 12.0, "crossfade clamped to 12s");
413
414 let bands = v["equalizerBands"].as_array().expect("bands array");
415 assert_eq!(bands.len(), EQ_BANDS.len(), "band vector normalised to 10");
416 assert_eq!(bands[0], EQ_GAIN_MAX as f64, "gain clamped to +12dB");
417 assert_eq!(bands[1], EQ_GAIN_MIN as f64, "gain clamped to -12dB");
418 }
419
420 #[test]
426 fn test_audio_settings_jni_payload_key_contract() {
427 let json = audio_settings_jni_payload(&AudioSettings::default()).expect("serialises");
428 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
429
430 for key in [
431 "crossfadeDuration",
432 "gaplessPlayback",
433 "normalizeVolume",
434 "volumeLevel",
435 "equalizerEnabled",
436 "equalizerBands",
437 ] {
438 assert!(v.get(key).is_some(), "JNI payload must carry `{key}`");
439 }
440
441 assert_eq!(v["volumeLevel"], "normal");
444 }
445
446 #[test]
447 fn test_default_settings() {
448 let settings = AudioSettings::default();
449 assert_eq!(settings.crossfade_duration, 0.0);
450 assert!(settings.gapless_playback);
451 assert!(!settings.normalize_volume);
452 assert_eq!(settings.volume_level, VolumeLevel::Normal);
453 assert!(!settings.equalizer_enabled);
455 assert_eq!(settings.equalizer_bands, vec![0.0; EQ_BANDS.len()]);
456 }
457
458 #[test]
462 fn test_eq_preset_curves() {
463 for preset in EqPreset::ALL {
464 assert_eq!(
465 preset.gains().len(),
466 EQ_BANDS.len(),
467 "preset {:?} must have one gain per band",
468 preset
469 );
470 for g in preset.gains() {
472 assert!(
473 (EQ_GAIN_MIN..=EQ_GAIN_MAX).contains(&g),
474 "preset {:?} gain {} out of range",
475 preset,
476 g
477 );
478 }
479 }
480 assert_eq!(EqPreset::Flat.gains(), [0.0; 10]);
481 let bass = EqPreset::BassBoost.gains();
483 assert!(bass[0] > 0.0 && bass[9] == 0.0);
484 }
485
486 #[test]
491 fn test_eq_normalisation() {
492 let s = AudioSettings {
494 equalizer_bands: vec![100.0, -100.0, 3.0],
495 ..Default::default()
496 }
497 .with_equalizer_normalised();
498 assert_eq!(s.equalizer_bands.len(), EQ_BANDS.len());
499 assert_eq!(s.equalizer_bands[0], EQ_GAIN_MAX);
500 assert_eq!(s.equalizer_bands[1], EQ_GAIN_MIN);
501 assert_eq!(s.equalizer_bands[2], 3.0);
502 assert_eq!(s.equalizer_bands[9], 0.0);
504
505 let long = AudioSettings {
507 equalizer_bands: vec![1.0; 20],
508 ..Default::default()
509 }
510 .with_equalizer_normalised();
511 assert_eq!(long.equalizer_bands.len(), EQ_BANDS.len());
512 }
513
514 #[test]
518 fn test_audio_settings_eq_backward_compat() {
519 let json = r#"{"crossfadeDuration":0.0,"gaplessPlayback":true,"normalizeVolume":false,"volumeLevel":"normal"}"#;
520 let parsed: AudioSettings = serde_json::from_str(json).unwrap();
521 assert!(!parsed.equalizer_enabled);
522 assert_eq!(parsed.equalizer_bands, vec![0.0; EQ_BANDS.len()]);
523 }
524
525 #[test]
529 fn test_audio_settings_eq_serialization() {
530 let settings = AudioSettings {
531 equalizer_enabled: true,
532 equalizer_bands: EqPreset::Rock.gains().to_vec(),
533 ..Default::default()
534 };
535 let json = serde_json::to_string(&settings).unwrap();
536 assert!(json.contains("\"equalizerEnabled\":true"));
537 assert!(json.contains("\"equalizerBands\":"));
538
539 let parsed: AudioSettings = serde_json::from_str(&json).unwrap();
540 assert!(parsed.equalizer_enabled);
541 assert_eq!(parsed.equalizer_bands, EqPreset::Rock.gains().to_vec());
542 }
543
544 #[test]
545 fn test_volume_level_lufs() {
546 assert_eq!(VolumeLevel::Loud.target_lufs(), -11.0);
547 assert_eq!(VolumeLevel::Normal.target_lufs(), -14.0);
548 assert_eq!(VolumeLevel::Quiet.target_lufs(), -23.0);
549 }
550
551 #[test]
552 fn test_crossfade_clamping() {
553 let settings = AudioSettings {
554 crossfade_duration: 20.0,
555 ..Default::default()
556 }
557 .with_crossfade_clamped();
558 assert_eq!(settings.crossfade_duration, 12.0);
559
560 let settings = AudioSettings {
561 crossfade_duration: -5.0,
562 ..Default::default()
563 }
564 .with_crossfade_clamped();
565 assert_eq!(settings.crossfade_duration, 0.0);
566 }
567
568 #[test]
569 fn test_settings_serialization() {
570 let settings = AudioSettings {
571 crossfade_duration: 5.0,
572 gapless_playback: true,
573 normalize_volume: true,
574 volume_level: VolumeLevel::Loud,
575 ..Default::default()
576 };
577
578 let json = serde_json::to_string(&settings).unwrap();
579 assert!(json.contains("\"crossfadeDuration\":5.0"));
580 assert!(json.contains("\"gaplessPlayback\":true"));
581 assert!(json.contains("\"normalizeVolume\":true"));
582 assert!(json.contains("\"volumeLevel\":\"loud\""));
583
584 let parsed: AudioSettings = serde_json::from_str(&json).unwrap();
585 assert_eq!(parsed.crossfade_duration, 5.0);
586 assert_eq!(parsed.volume_level, VolumeLevel::Loud);
587 }
588
589 #[test]
590 fn test_video_default_settings() {
591 let settings = VideoSettings::default();
592 assert!(settings.auto_play_next_episode);
593 assert_eq!(settings.auto_play_countdown_seconds, 10);
594 assert_eq!(settings.auto_play_max_episodes, 0);
595 }
596
597 #[test]
598 fn test_video_countdown_clamping() {
599 let settings = VideoSettings {
600 auto_play_countdown_seconds: 60,
601 ..Default::default()
602 }
603 .with_countdown_clamped();
604 assert_eq!(settings.auto_play_countdown_seconds, 30);
605
606 let settings = VideoSettings {
607 auto_play_countdown_seconds: 2,
608 ..Default::default()
609 }
610 .with_countdown_clamped();
611 assert_eq!(settings.auto_play_countdown_seconds, 5);
612 }
613
614 #[test]
615 fn test_video_settings_serialization() {
616 let settings = VideoSettings {
617 auto_play_next_episode: false,
618 auto_play_countdown_seconds: 15,
619 auto_play_max_episodes: 5,
620 streaming_quality: StreamingQuality::Mbps4,
621 };
622
623 let json = serde_json::to_string(&settings).unwrap();
624 assert!(json.contains("\"autoPlayNextEpisode\":false"));
625 assert!(json.contains("\"autoPlayCountdownSeconds\":15"));
626 assert!(json.contains("\"autoPlayMaxEpisodes\":5"));
627 assert!(json.contains("\"streamingQuality\":\"mbps4\""));
628
629 let parsed: VideoSettings = serde_json::from_str(&json).unwrap();
630 assert!(!parsed.auto_play_next_episode);
631 assert_eq!(parsed.auto_play_countdown_seconds, 15);
632 assert_eq!(parsed.auto_play_max_episodes, 5);
633 }
634
635 #[test]
636 fn test_video_settings_backward_compat() {
637 let json = r#"{"autoPlayNextEpisode":true,"autoPlayCountdownSeconds":10}"#;
639 let parsed: VideoSettings = serde_json::from_str(json).unwrap();
640 assert!(parsed.auto_play_next_episode);
641 assert_eq!(parsed.auto_play_countdown_seconds, 10);
642 assert_eq!(parsed.auto_play_max_episodes, 0);
643 assert_eq!(parsed.streaming_quality, StreamingQuality::Original);
647 }
648
649 #[test]
655 fn test_streaming_quality_budget_is_internally_consistent() {
656 for quality in StreamingQuality::ALL {
657 let Some(total) = quality.max_bitrate() else {
658 assert_eq!(
659 quality,
660 StreamingQuality::Original,
661 "only Original may be uncapped"
662 );
663 assert!(quality.video_bitrate().is_none());
664 assert!(quality.max_height().is_none());
665 continue;
666 };
667
668 let video = quality.video_bitrate().expect("a capped step caps video");
669 assert_eq!(
670 video + quality.audio_bitrate(),
671 total,
672 "{:?}: video + audio must equal the cap",
673 quality
674 );
675 assert!(
676 video > 0,
677 "{:?}: audio must not consume the budget",
678 quality
679 );
680 assert!(!quality.label().is_empty());
681 assert!(!quality.detail().is_empty());
682 }
683 }
684
685 #[test]
691 fn test_streaming_quality_ladder_descends() {
692 let steps = StreamingQuality::ALL;
693 for pair in steps.windows(2) {
694 let (higher, lower) = (pair[0], pair[1]);
695 let higher_bitrate = higher.max_bitrate().unwrap_or(u64::MAX);
696 let lower_bitrate = lower.max_bitrate().unwrap_or(u64::MAX);
697 assert!(
698 higher_bitrate > lower_bitrate,
699 "{:?} must sit above {:?}",
700 higher,
701 lower
702 );
703 assert!(
704 higher.max_height().unwrap_or(u32::MAX) >= lower.max_height().unwrap_or(u32::MAX),
705 "{:?} must not cap resolution below {:?}",
706 higher,
707 lower
708 );
709 assert!(higher.audio_bitrate() >= lower.audio_bitrate());
710 }
711 }
712
713 #[test]
718 fn test_streaming_quality_round_trips_through_json() {
719 for quality in StreamingQuality::ALL {
720 let json = serde_json::to_string(&quality).expect("serialises");
721 let parsed: StreamingQuality = serde_json::from_str(&json).expect("parses back");
722 assert_eq!(parsed, quality);
723 }
724 assert_eq!(
725 serde_json::to_string(&StreamingQuality::Mbps10).unwrap(),
726 "\"mbps10\""
727 );
728 }
729}