//! Audio and video playback settings commands. //! //! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033, UR-074 | DR-025, DR-030, DR-034, DR-035, DR-036, DR-162, IR-020 use std::sync::Arc; use log::{info, warn}; use tauri::{Manager, State}; use super::{PlayerStateWrapper, VideoSettingsWrapper}; use crate::commands::storage::DatabaseWrapper; use crate::player::AutoplaySettings; use crate::settings::{AudioSettings, EqPreset, StreamingQuality, VideoSettings}; use crate::storage::db_service::{DatabaseService, Query, QueryParam}; use crate::utils::lock::MutexSafe; /// `app_settings` key holding the persisted streaming bandwidth ceiling. /// /// The cap is persisted (unlike the rest of `VideoSettings`, which is /// process-lifetime state) because forgetting it is the one failure that costs /// the user something real: a limit set for a metered connection that silently /// reverts to uncapped on the next launch spends their data allowance without /// ever showing them a changed setting. /// /// TRACES: UR-074 | DR-162 const STREAMING_QUALITY_KEY: &str = "streaming_quality"; #[tauri::command] #[specta::specta] pub async fn player_set_audio_settings( player: State<'_, PlayerStateWrapper>, settings: AudioSettings, ) -> Result { // Validate/normalise domain values before applying: clamp crossfade to its // range and normalise the equalizer band vector (length + gain clamps). let validated = settings .with_crossfade_clamped() .with_equalizer_normalised(); let mut controller = player.0.lock().await; controller .set_audio_settings(&validated) .map_err(|e| e.to_string())?; Ok(controller.audio_settings()) } /// The built-in equalizer presets and their per-band gain curves (dB), for the /// settings UI. The curve numbers are domain data defined by the band layout, /// so the frontend reads them here rather than encoding them. /// /// TRACES: UR-027 | DR-030 #[tauri::command] #[specta::specta] pub async fn player_get_eq_presets() -> Result)>, String> { Ok(EqPreset::ALL .iter() .map(|p| (*p, p.gains().to_vec())) .collect()) } #[tauri::command] #[specta::specta] pub async fn player_get_audio_settings( player: State<'_, PlayerStateWrapper>, ) -> Result { let controller = player.0.lock().await; Ok(controller.audio_settings()) } #[tauri::command] #[specta::specta] pub async fn player_set_video_settings( video_settings: State<'_, VideoSettingsWrapper>, player: State<'_, PlayerStateWrapper>, db: State<'_, DatabaseWrapper>, settings: VideoSettings, ) -> Result { let validated = settings.with_countdown_clamped(); { let mut current = video_settings.0.lock().map_err(|e| e.to_string())?; *current = validated.clone(); } // Drop MutexGuard before await // The bandwidth ceiling is read by the repository's URL builders and by the // PlaybackInfo negotiation, neither of which can see this wrapper. // TRACES: UR-074 | DR-162 crate::repository::online::set_streaming_quality(validated.streaming_quality); persist_streaming_quality(&db, validated.streaming_quality).await; // Sync to PlayerController's autoplay settings so on_playback_ended() uses current values let controller = player.0.lock().await; controller.set_autoplay_settings(AutoplaySettings { enabled: validated.auto_play_next_episode, countdown_seconds: validated.auto_play_countdown_seconds, max_episodes: validated.auto_play_max_episodes, }); Ok(validated) } /// The bandwidth ceilings the quality picker may offer, each with the label and /// one-line detail to show for it, highest first. /// /// The ladder and its numbers are Jellyfin encoding domain vocabulary, so the /// frontend reads them here rather than encoding them — the same arrangement as /// [`player_get_eq_presets`]. /// /// TRACES: UR-074 | DR-162 #[tauri::command] #[specta::specta] pub async fn player_get_streaming_qualities( ) -> Result, String> { Ok(StreamingQuality::ALL .iter() .map(|q| (*q, q.label().to_string(), q.detail().to_string())) .collect()) } /// Write the ceiling to `app_settings`. Failure is logged, not returned: the /// setting has already been applied in memory, and refusing the whole call /// because the write failed would leave the UI showing a cap that *is* active. /// /// TRACES: UR-074 | DR-162 async fn persist_streaming_quality(db: &State<'_, DatabaseWrapper>, quality: StreamingQuality) { let db_service = { let database = db.0.lock_safe(); Arc::new(database.service()) }; let encoded = match serde_json::to_string(&quality) { Ok(value) => value, Err(e) => { warn!("[VideoSettings] Failed to encode streaming quality: {}", e); return; } }; let query = Query::with_params( "INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", vec![ QueryParam::String(STREAMING_QUALITY_KEY.to_string()), QueryParam::String(encoded), ], ); if let Err(e) = db_service.execute(query).await { warn!("[VideoSettings] Failed to persist streaming quality: {}", e); } } /// Restore the persisted bandwidth ceiling at startup, into both the repository /// (which enforces it) and `VideoSettings` (which the settings UI reads). /// /// Called from the Tauri `setup` hook. A missing or unreadable row leaves the /// default — uncapped — in place, so a database problem degrades to the old /// behaviour rather than to an arbitrary limit. /// /// TRACES: UR-074 | DR-162 pub async fn restore_streaming_quality(app: &tauri::AppHandle) { let db_service = { let Some(db) = app.try_state::() else { warn!("[VideoSettings] No database available; streaming quality stays uncapped"); return; }; let database = db.0.lock_safe(); Arc::new(database.service()) }; let query = Query::with_params( "SELECT value FROM app_settings WHERE key = ?", vec![QueryParam::String(STREAMING_QUALITY_KEY.to_string())], ); let stored: Option = match db_service.query_optional(query, |row| row.get(0)).await { Ok(value) => value, Err(e) => { warn!("[VideoSettings] Failed to read streaming quality: {}", e); return; } }; let Some(stored) = stored else { return }; let quality: StreamingQuality = match serde_json::from_str(&stored) { Ok(quality) => quality, Err(e) => { warn!( "[VideoSettings] Ignoring unrecognised persisted streaming quality {:?}: {}", stored, e ); return; } }; crate::repository::online::set_streaming_quality(quality); if let Some(video_settings) = app.try_state::() { video_settings.0.lock_safe().streaming_quality = quality; } info!( "[VideoSettings] Restored streaming quality cap: {}", quality.label() ); } #[tauri::command] #[specta::specta] pub async fn player_get_video_settings( video_settings: State<'_, VideoSettingsWrapper>, ) -> Result { let current = video_settings.0.lock().map_err(|e| e.to_string())?; Ok(current.clone()) }