- Add #[specta::specta] to all 201 #[tauri::command] functions. - Derive specta::Type on all IPC DTOs (repository/types, settings, player/storage/ download command DTOs, player enums, jellyfin SessionInfo/NowPlayingItem/PlayState, ThumbnailCacheStats, DownloadInfo, CacheConfig, etc.). - Replace tauri::generate_handler! with a tauri_specta::Builder + collect_commands! in lib.rs (exports bindings.ts in debug builds). Two contract changes required by specta constraints (frontend migration follows): - specta caps command arity at 10 args: download_item_and_start / download_item / download_video now take a single request struct (params bundled, body unchanged via destructuring). - specta can't parse split serde rename_all: SessionInfo/NowPlayingItem/PlayState switched to rename_all = "PascalCase" (Jellyfin deserialization preserved; these now serialize PascalCase to the frontend). cargo check --lib is clean (0 errors). Frontend migration to bindings.ts is the next step.
204 lines
6.3 KiB
Rust
204 lines
6.3 KiB
Rust
//! TRACES: UR-023, UR-031, UR-032, UR-033 | DR-034, DR-035, DR-036, DR-048
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Volume normalization levels matching Spotify's presets
|
|
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum VolumeLevel {
|
|
/// Louder output (-11 LUFS)
|
|
Loud,
|
|
/// Default level (-14 LUFS)
|
|
#[default]
|
|
Normal,
|
|
/// Quieter output (-23 LUFS)
|
|
Quiet,
|
|
}
|
|
|
|
impl VolumeLevel {
|
|
/// Get the target LUFS value for this volume level
|
|
pub fn target_lufs(&self) -> f32 {
|
|
match self {
|
|
VolumeLevel::Loud => -11.0,
|
|
VolumeLevel::Normal => -14.0,
|
|
VolumeLevel::Quiet => -23.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Audio playback settings
|
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AudioSettings {
|
|
/// Crossfade duration in seconds (0 = disabled, max 12)
|
|
pub crossfade_duration: f32,
|
|
/// Enable gapless playback between tracks
|
|
pub gapless_playback: bool,
|
|
/// Enable volume normalization
|
|
pub normalize_volume: bool,
|
|
/// Target volume level for normalization
|
|
pub volume_level: VolumeLevel,
|
|
}
|
|
|
|
impl Default for AudioSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
crossfade_duration: 0.0,
|
|
gapless_playback: true,
|
|
normalize_volume: false,
|
|
volume_level: VolumeLevel::Normal,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AudioSettings {
|
|
/// Clamp crossfade duration to valid range (0-12 seconds)
|
|
pub fn with_crossfade_clamped(mut self) -> Self {
|
|
self.crossfade_duration = self.crossfade_duration.clamp(0.0, 12.0);
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Video playback settings
|
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct VideoSettings {
|
|
/// Enable auto-play of next episode (with countdown)
|
|
pub auto_play_next_episode: bool,
|
|
/// Countdown duration in seconds before auto-play (5-30 seconds)
|
|
pub auto_play_countdown_seconds: u32,
|
|
/// Maximum number of episodes to auto-play consecutively (0 = unlimited)
|
|
#[serde(default)]
|
|
pub auto_play_max_episodes: u32,
|
|
}
|
|
|
|
impl Default for VideoSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
auto_play_next_episode: true,
|
|
auto_play_countdown_seconds: 10,
|
|
auto_play_max_episodes: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl VideoSettings {
|
|
/// Clamp countdown duration to valid range (5-30 seconds)
|
|
pub fn with_countdown_clamped(mut self) -> Self {
|
|
self.auto_play_countdown_seconds = self.auto_play_countdown_seconds.clamp(5, 30);
|
|
self
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_default_settings() {
|
|
let settings = AudioSettings::default();
|
|
assert_eq!(settings.crossfade_duration, 0.0);
|
|
assert!(settings.gapless_playback);
|
|
assert!(!settings.normalize_volume);
|
|
assert_eq!(settings.volume_level, VolumeLevel::Normal);
|
|
}
|
|
|
|
#[test]
|
|
fn test_volume_level_lufs() {
|
|
assert_eq!(VolumeLevel::Loud.target_lufs(), -11.0);
|
|
assert_eq!(VolumeLevel::Normal.target_lufs(), -14.0);
|
|
assert_eq!(VolumeLevel::Quiet.target_lufs(), -23.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_crossfade_clamping() {
|
|
let settings = AudioSettings {
|
|
crossfade_duration: 20.0,
|
|
..Default::default()
|
|
}
|
|
.with_crossfade_clamped();
|
|
assert_eq!(settings.crossfade_duration, 12.0);
|
|
|
|
let settings = AudioSettings {
|
|
crossfade_duration: -5.0,
|
|
..Default::default()
|
|
}
|
|
.with_crossfade_clamped();
|
|
assert_eq!(settings.crossfade_duration, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_settings_serialization() {
|
|
let settings = AudioSettings {
|
|
crossfade_duration: 5.0,
|
|
gapless_playback: true,
|
|
normalize_volume: true,
|
|
volume_level: VolumeLevel::Loud,
|
|
};
|
|
|
|
let json = serde_json::to_string(&settings).unwrap();
|
|
assert!(json.contains("\"crossfadeDuration\":5.0"));
|
|
assert!(json.contains("\"gaplessPlayback\":true"));
|
|
assert!(json.contains("\"normalizeVolume\":true"));
|
|
assert!(json.contains("\"volumeLevel\":\"loud\""));
|
|
|
|
let parsed: AudioSettings = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(parsed.crossfade_duration, 5.0);
|
|
assert_eq!(parsed.volume_level, VolumeLevel::Loud);
|
|
}
|
|
|
|
#[test]
|
|
fn test_video_default_settings() {
|
|
let settings = VideoSettings::default();
|
|
assert!(settings.auto_play_next_episode);
|
|
assert_eq!(settings.auto_play_countdown_seconds, 10);
|
|
assert_eq!(settings.auto_play_max_episodes, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_video_countdown_clamping() {
|
|
let settings = VideoSettings {
|
|
auto_play_countdown_seconds: 60,
|
|
..Default::default()
|
|
}
|
|
.with_countdown_clamped();
|
|
assert_eq!(settings.auto_play_countdown_seconds, 30);
|
|
|
|
let settings = VideoSettings {
|
|
auto_play_countdown_seconds: 2,
|
|
..Default::default()
|
|
}
|
|
.with_countdown_clamped();
|
|
assert_eq!(settings.auto_play_countdown_seconds, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_video_settings_serialization() {
|
|
let settings = VideoSettings {
|
|
auto_play_next_episode: false,
|
|
auto_play_countdown_seconds: 15,
|
|
auto_play_max_episodes: 5,
|
|
};
|
|
|
|
let json = serde_json::to_string(&settings).unwrap();
|
|
assert!(json.contains("\"autoPlayNextEpisode\":false"));
|
|
assert!(json.contains("\"autoPlayCountdownSeconds\":15"));
|
|
assert!(json.contains("\"autoPlayMaxEpisodes\":5"));
|
|
|
|
let parsed: VideoSettings = serde_json::from_str(&json).unwrap();
|
|
assert!(!parsed.auto_play_next_episode);
|
|
assert_eq!(parsed.auto_play_countdown_seconds, 15);
|
|
assert_eq!(parsed.auto_play_max_episodes, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_video_settings_backward_compat() {
|
|
// Old JSON without auto_play_max_episodes field
|
|
let json = r#"{"autoPlayNextEpisode":true,"autoPlayCountdownSeconds":10}"#;
|
|
let parsed: VideoSettings = serde_json::from_str(json).unwrap();
|
|
assert!(parsed.auto_play_next_episode);
|
|
assert_eq!(parsed.auto_play_countdown_seconds, 10);
|
|
assert_eq!(parsed.auto_play_max_episodes, 0);
|
|
}
|
|
}
|