🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
The feature shipped tagged against DR-160, which a parallel session had claimed for picture-in-picture in the meantime. Renumbered to DR-162 across the Rust and frontend TRACES comments (the PiP tags in VideoPlayer.svelte, pictureInPicture.ts and nativeVideo.ts keep DR-160) and regenerated bindings.ts. Adds the requirement rows the tags point at: UR-074 for the user need, and DR-162 covering why the cap has to reach the PlaybackInfo negotiation and not only the transcode URL, why the ceiling is process-wide, and why the Settings default persists while the in-player override does not. Notes that this gives UR-070 its resume-at-the-same-point mechanism while the server-offered rendition list that requirement also asks for stays proposed. UT-156/157 record what the tests pin. docs/specs/streaming-bitrate-cap.md carries the layer assignment — the step definitions, the video/audio split, the resolution pairing and the reload decision are all Rust; the frontend holds a serde token and the labels it was handed. TRACES: UR-074 | DR-162 | UT-156, UT-157
212 lines
7.4 KiB
Rust
212 lines
7.4 KiB
Rust
//! 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<AudioSettings, String> {
|
|
// 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<Vec<(EqPreset, Vec<f32>)>, 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<AudioSettings, String> {
|
|
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<VideoSettings, String> {
|
|
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<Vec<(StreamingQuality, String, String)>, 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::<DatabaseWrapper>() 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<String> = 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::<VideoSettingsWrapper>() {
|
|
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<VideoSettings, String> {
|
|
let current = video_settings.0.lock().map_err(|e| e.to_string())?;
|
|
Ok(current.clone())
|
|
}
|