diff --git a/src-tauri/src/commands/player/mod.rs b/src-tauri/src/commands/player/mod.rs index 07d2ac5f..cdd0cd2e 100644 --- a/src-tauri/src/commands/player/mod.rs +++ b/src-tauri/src/commands/player/mod.rs @@ -342,6 +342,29 @@ pub enum AudioTrackSwitchResponse { }, } +/// Response for a mid-playback streaming-quality change. +/// +/// Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller +/// has to reload anything, so no strategy branch lives in the UI. +/// +/// TRACES: UR-074 | DR-160 +#[derive(specta::Type, Debug, Serialize)] +#[serde(tag = "strategy", rename_all = "camelCase")] +pub enum StreamQualityResponse { + /// The native backend was reloaded here; nothing left for the frontend. + Native { + /// Position playback resumed at. + position: f64, + }, + /// HTML5 must reload its element with this URL. + ReloadStream { + /// New stream URL, already transcoded to the requested ceiling. + new_url: String, + /// Position to resume from. + position: f64, + }, +} + /// Helper function to create MediaItem from video request /// /// PlayItemRequest is now video-only, so we create a video MediaItem. @@ -1447,6 +1470,112 @@ pub async fn player_switch_audio_track( } } +/// Change the bandwidth ceiling of the video that is playing *right now*. +/// +/// A cap is a property of the stream the server is producing, so unlike a volume +/// change it cannot be applied to a stream already in flight — the stream has to +/// be re-opened at the new quality and resumed at the current position. That is +/// the same reload the transcoded-seek and audio-track paths use, and the same +/// two-sided split: HTML5 gets the URL back and reloads its own element, while a +/// native backend is reloaded here. +/// +/// The change applies to this playback *and* to everything started afterwards +/// (it sets the process-wide ceiling), but it is deliberately **not** persisted: +/// the in-player picker is a "this film, this connection" control, and the +/// durable default belongs to Settings. `player_set_video_settings` is the one +/// that writes to the database. +/// +/// TRACES: UR-074 | DR-160 +#[tauri::command] +#[specta::specta] +pub async fn player_set_stream_quality( + player: State<'_, PlayerStateWrapper>, + repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, + video_settings: State<'_, VideoSettingsWrapper>, + repository_handle: String, + quality: crate::settings::StreamingQuality, + use_html5: bool, + current_position: Option, + media_source_id: Option, + audio_stream_index: Option, +) -> Result { + info!( + "[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})", + quality.label(), + use_html5, + current_position + ); + + let repository = repository_manager + .0 + .get(&repository_handle) + .ok_or("Repository not found - user may need to log in")?; + + let jellyfin_item_id = { + let controller = player.0.lock().await; + let queue_arc = controller.queue(); + let queue = queue_arc.lock().map_err(|e| e.to_string())?; + + let current_item = queue.current().ok_or("No item currently playing")?; + + if current_item.media_type != MediaType::Video { + return Err("Current item is not a video".to_string()); + } + + current_item + .jellyfin_id() + .ok_or("Current item has no Jellyfin ID")? + .to_string() + }; + + // Set the ceiling *before* building the URL — the builder reads it. + crate::repository::online::set_streaming_quality(quality); + { + let mut settings = video_settings.0.lock().map_err(|e| e.to_string())?; + settings.streaming_quality = quality; + } + + let position = current_position.unwrap_or(0.0); + let new_url = repository + .get_video_stream_url( + &jellyfin_item_id, + media_source_id.as_deref(), + current_position, + audio_stream_index, + ) + .await + .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?; + + if use_html5 { + return Ok(StreamQualityResponse::ReloadStream { new_url, position }); + } + + // Native backend (Android/ExoPlayer): stop, repoint the queue entry at the + // new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`. + // The URL already carries `StartTimeTicks`, so the reloaded stream begins at + // the current position rather than at zero. + { + let controller = player.0.lock().await; + controller.stop().map_err(|e| e.to_string())?; + + let queue_arc = controller.queue(); + { + let mut queue = queue_arc.lock().map_err(|e| e.to_string())?; + if !queue.update_current_stream_url(new_url.clone()) { + return Err("Failed to update stream URL in queue".to_string()); + } + } + + let queue = queue_arc.lock().map_err(|e| e.to_string())?; + let updated_item = queue.current().ok_or("No current item after URL update")?; + controller + .load_and_play(updated_item) + .map_err(|e| e.to_string())?; + } + + Ok(StreamQualityResponse::Native { position }) +} + #[tauri::command] #[specta::specta] pub async fn player_set_audio_track( diff --git a/src-tauri/src/commands/player/settings.rs b/src-tauri/src/commands/player/settings.rs index 39e351b4..58199dcf 100644 --- a/src-tauri/src/commands/player/settings.rs +++ b/src-tauri/src/commands/player/settings.rs @@ -1,12 +1,29 @@ //! Audio and video playback settings commands. //! -//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033 | DR-025, DR-030, DR-034, DR-035, DR-036, IR-020 +//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033, UR-074 | DR-025, DR-030, DR-034, DR-035, DR-036, DR-160, IR-020 -use tauri::State; +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, VideoSettings}; +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-160 +const STREAMING_QUALITY_KEY: &str = "streaming_quality"; #[tauri::command] #[specta::specta] @@ -54,6 +71,7 @@ pub async fn player_get_audio_settings( 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(); @@ -62,6 +80,12 @@ pub async fn player_set_video_settings( *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-160 + 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 { @@ -73,6 +97,110 @@ pub async fn player_set_video_settings( 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-160 +#[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-160 +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-160 +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( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0fe36157..8c96edbb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -130,6 +130,7 @@ use commands::{ player_get_session, player_get_sleep_timer, player_get_status, + player_get_streaming_qualities, player_get_video_settings, // Preload commands player_local_media_path, @@ -159,6 +160,7 @@ use commands::{ player_set_cache_config, // Sleep timer and autoplay commands player_set_sleep_timer, + player_set_stream_quality, player_set_subtitle_track, player_set_video_settings, player_set_volume, @@ -711,6 +713,8 @@ fn specta_builder() -> Builder { player_get_eq_presets, player_set_video_settings, player_get_video_settings, + player_get_streaming_qualities, + player_set_stream_quality, // Sleep timer and autoplay commands player_set_sleep_timer, player_cancel_sleep_timer, @@ -1242,6 +1246,20 @@ pub fn run() { let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default())); app.manage(video_settings); + // Restore the persisted streaming bandwidth ceiling. Deferred to the + // async runtime because the read is async, and ordered after the + // wrapper above because it writes into it. Until it lands, streams + // are uncapped — the pre-existing behaviour — and no playback can + // have started this early anyway (login happens after setup). + // + // TRACES: UR-074 | DR-160 + { + let handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + crate::commands::restore_streaming_quality(&handle).await; + }); + } + // Initialize thumbnail cache info!("[INIT] Initializing thumbnail cache..."); let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") { diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index 9c84174a..d076b4c2 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -3,11 +3,46 @@ use async_trait::async_trait; use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use super::{types::*, MediaRepository}; use crate::connectivity::ConnectivityReporter; use crate::jellyfin::HttpClient; +use crate::settings::StreamingQuality; +use crate::utils::lock::RwLockSafe; + +/// The bandwidth ceiling every video stream this process opens is built against. +/// +/// Process-wide rather than a field on [`OnlineRepository`] because it is a user +/// preference about *this device's connection*, not about a server session: it +/// must survive a repository being rebuilt on re-login, and every URL builder and +/// the `PlaybackInfo` negotiation have to agree on it or the cap leaks (the +/// negotiation would authorise a direct play the URL builder then never gets to +/// constrain). Same shape as `offline::INCLUDE_CATALOG_BROWSE`. +/// +/// Set from `player_set_video_settings` / `player_set_stream_quality`, and +/// restored from the database at startup. +/// +/// TRACES: UR-074 | DR-160 +static STREAMING_QUALITY: RwLock = RwLock::new(StreamingQuality::Original); + +/// Apply a bandwidth ceiling to every subsequently-opened video stream. +/// +/// Streams already playing keep the bitrate they were opened at — a cap is a +/// property of the URL the server is transcoding for, so changing it mid-stream +/// requires re-opening at the new quality (`player_set_stream_quality`). +/// +/// TRACES: UR-074 | DR-160 +pub fn set_streaming_quality(quality: StreamingQuality) { + *STREAMING_QUALITY.write_safe() = quality; +} + +/// The ceiling currently applied to new video streams. +/// +/// TRACES: UR-074 | DR-160 +pub fn streaming_quality() -> StreamingQuality { + *STREAMING_QUALITY.read_safe() +} /// A single actor returned by the JRay plugin's "context at time t" endpoint. /// @@ -381,7 +416,12 @@ impl OnlineRepository { /// which manifests as playback never starting. `StartTimeTicks` makes the /// server begin the transcode at the requested position. /// - /// TRACES: UR-004 | DR-140 | UT-130 + /// The stream is built against the current [`streaming_quality`] ceiling: + /// `MaxStreamingBitrate`/`VideoBitrate`/`AudioBitrate`, plus a `MaxHeight` + /// that suits the budget. `Original` keeps the historical 20/18 Mbps + /// allowance, which is a transcode ceiling rather than a user-facing limit. + /// + /// TRACES: UR-004, UR-074 | DR-140, DR-160 | UT-130, UT-156 pub async fn get_video_stream_url( &self, item_id: &str, @@ -392,6 +432,13 @@ impl OnlineRepository { // Convert seconds to ticks (10,000,000 ticks per second) let start_time_ticks = start_time_seconds.map(|seconds| (seconds * 10_000_000.0) as i64); + let quality = streaming_quality(); + // `Original` is uncapped as a *user* setting, but a transcode still needs + // a ceiling to encode against — keep the values this endpoint has always + // used so nothing changes for the default. + let max_bitrate = quality.max_bitrate().unwrap_or(20_000_000); + let video_bitrate = quality.video_bitrate().unwrap_or(18_000_000); + // Build an HLS transcode URL. VideoCodec lists h264 first so the server // transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode. let mut params = vec![ @@ -399,9 +446,9 @@ impl OnlineRepository { ("DeviceId", "jellytau-tauri".to_string()), ("VideoCodec", "h264".to_string()), ("AudioCodec", "aac".to_string()), - ("MaxStreamingBitrate", "20000000".to_string()), - ("VideoBitrate", "18000000".to_string()), - ("AudioBitrate", "384000".to_string()), + ("MaxStreamingBitrate", max_bitrate.to_string()), + ("VideoBitrate", video_bitrate.to_string()), + ("AudioBitrate", quality.audio_bitrate().to_string()), ( "TranscodingMaxAudioChannels", super::device_profile::max_audio_channels().to_string(), @@ -411,6 +458,12 @@ impl OnlineRepository { ("TranscodingProtocol", "hls".to_string()), ]; + // Scale the picture down to what the budget can carry. Omitted for the + // uncapped steps so the source resolution is preserved. + if let Some(height) = quality.max_height() { + params.push(("MaxHeight", height.to_string())); + } + // Only pin an audio track when the user actually picked one. Jellyfin's // `MediaStream.Index` is global across *all* streams in a media source, so // index 0 is the video stream on virtually every file — defaulting to 0 @@ -481,7 +534,14 @@ impl OnlineRepository { ("AudioCodec", "mp3".to_string()), ("TranscodingContainer", "mp3".to_string()), ("TranscodingProtocol", "http".to_string()), - ("MaxStreamingBitrate", "384000".to_string()), + // Audio-only is already far under any video cap, but a user on the + // bottom rungs of the ladder asked for *less traffic*, so take the + // lower of the two rather than always 384 kbps. + // TRACES: UR-074 | DR-160 + ( + "MaxStreamingBitrate", + streaming_quality().audio_bitrate().min(384_000).to_string(), + ), ]; // Carry the track over only if one was actually selected — index 0 is the @@ -1406,11 +1466,29 @@ impl MediaRepository for OnlineRepository { let max_audio_channels = super::device_profile::max_audio_channels().to_string(); info!("[DeviceProfile] Max audio channels: {}", max_audio_channels); + // The user's bandwidth ceiling has to be part of the *negotiation*, not + // just the transcode URL: `max_static_bitrate` is what makes the server + // refuse to direct-play a source fatter than the cap, and without it a + // 30 Mbps remux is handed over untouched and every URL parameter + // downstream is moot. `Original` keeps the historical "no ceiling" + // sentinel so the default path negotiates exactly as before. + // + // TRACES: UR-074 | DR-160 + let quality = streaming_quality(); + let negotiated_bitrate = quality.max_bitrate().unwrap_or(999_999_999) as i64; + if let Some(cap) = quality.max_bitrate() { + info!( + "[DeviceProfile] Streaming quality cap active: {} ({} bps)", + quality.label(), + cap + ); + } + // Create device profile with detected hardware capabilities let device_profile = DeviceProfile { name: "JellyTau Native Player".to_string(), - max_streaming_bitrate: 999_999_999, - max_static_bitrate: 999_999_999, + max_streaming_bitrate: negotiated_bitrate, + max_static_bitrate: negotiated_bitrate, max_audio_channels: max_audio_channels.clone(), direct_play_profiles: vec![ DirectPlayProfile { @@ -1470,7 +1548,8 @@ impl MediaRepository for OnlineRepository { start_time_ticks: 0, is_playback: true, auto_open_live_stream: true, - max_streaming_bitrate: 20_000_000, // 20 Mbps + // The user's cap, or the historical 20 Mbps allowance when uncapped. + max_streaming_bitrate: quality.max_bitrate().unwrap_or(20_000_000) as i64, device_profile: Some(device_profile), // Now sending profile with detected codecs }; @@ -1633,7 +1712,10 @@ impl MediaRepository for OnlineRepository { user_id: self.user_id.clone(), auto_open_live_stream: true, is_playback: true, - max_streaming_bitrate: 20_000_000, + // Live TV is video like any other, so the user's cap applies here + // too — a channel opened at the source bitrate would walk straight + // past a limit set for the connection. TRACES: UR-074 | DR-160 + max_streaming_bitrate: streaming_quality().max_bitrate().unwrap_or(20_000_000), }; let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?; @@ -2233,6 +2315,7 @@ impl MediaRepository for OnlineRepository { #[cfg(test)] mod tests { use super::*; + use crate::utils::lock::MutexSafe; use std::sync::Arc; fn create_test_repository() -> OnlineRepository { @@ -2379,11 +2462,107 @@ mod tests { ); } + /// Serialises every test whose expectations depend on the process-wide + /// streaming ceiling, and restores the uncapped default afterwards — without + /// it, a capped test running concurrently changes what an uncapped one sees. + /// + /// TRACES: UR-074 | DR-160 + static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>); + + impl QualityFixture { + fn set(quality: StreamingQuality) -> Self { + let guard = QUALITY_LOCK.lock_safe(); + set_streaming_quality(quality); + Self(guard) + } + } + + impl Drop for QualityFixture { + fn drop(&mut self) { + set_streaming_quality(StreamingQuality::Original); + } + } + + /// A cap has to reach the transcode URL as all four of its parts: the total + /// ceiling, the split between video and audio, and the resolution the budget + /// can carry. Capping only `MaxStreamingBitrate` would leave the server + /// encoding 1080p into 2 Mbps. + /// + /// TRACES: UR-074 | DR-160 | UT-156 + #[tokio::test] + async fn test_video_stream_url_applies_bitrate_cap() { + let _fixture = QualityFixture::set(StreamingQuality::Mbps2); + let repo = create_test_repository(); + + let url = repo + .get_video_stream_url("vid-1", None, None, None) + .await + .unwrap(); + + assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}"); + // 2 Mbps total less the 192 kbps audio share — the two must not sum to + // more than the cap the user asked for. + assert!(url.contains("VideoBitrate=1808000"), "url: {url}"); + assert!(url.contains("AudioBitrate=192000"), "url: {url}"); + assert!(url.contains("MaxHeight=720"), "url: {url}"); + } + + /// The uncapped default must keep the exact transcode allowance this + /// endpoint has always used, and must not start constraining resolution. + /// + /// TRACES: UR-074 | DR-160 | UT-156 + #[tokio::test] + async fn test_video_stream_url_uncapped_keeps_legacy_allowance() { + let _fixture = QualityFixture::set(StreamingQuality::Original); + let repo = create_test_repository(); + + let url = repo + .get_video_stream_url("vid-1", None, None, None) + .await + .unwrap(); + + assert!(url.contains("MaxStreamingBitrate=20000000"), "url: {url}"); + assert!(url.contains("VideoBitrate=18000000"), "url: {url}"); + assert!(url.contains("AudioBitrate=384000"), "url: {url}"); + assert!( + !url.contains("MaxHeight"), + "uncapped must not scale the picture down: {url}" + ); + } + + /// The background-audio handoff is already cheap, but someone who capped the + /// connection at 720 kbps asked for less traffic than its fixed 384 kbps. + /// + /// TRACES: UR-040, UR-074 | DR-160 | UT-156 + #[tokio::test] + async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() { + { + let _fixture = QualityFixture::set(StreamingQuality::Kbps720); + let repo = create_test_repository(); + let url = repo + .get_audio_only_stream_url_for_video("vid-1", None, None, None) + .await + .unwrap(); + assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}"); + } + + let _fixture = QualityFixture::set(StreamingQuality::Original); + let repo = create_test_repository(); + let url = repo + .get_audio_only_stream_url_for_video("vid-1", None, None, None) + .await + .unwrap(); + assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}"); + } + #[tokio::test] async fn test_get_video_stream_url_returns_hls_with_position() { // Transcoded video resume/seek must produce an HLS master playlist with // StartTimeTicks, not a progressive stream.mp4 (which never starts playing // for HEVC sources). See get_video_stream_url docs. + let _fixture = QualityFixture::set(StreamingQuality::Original); let repo = create_test_repository(); let url = repo @@ -2405,6 +2584,7 @@ mod tests { #[tokio::test] async fn test_get_video_stream_url_omits_position_when_absent() { + let _fixture = QualityFixture::set(StreamingQuality::Original); let repo = create_test_repository(); let url = repo diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 2ffbfcab..5036d7d4 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -148,6 +148,138 @@ impl AudioSettings { } } +/// A ceiling on how much bandwidth a *video* stream may consume. +/// +/// A quality step is a bundle of concrete transcode parameters — total stream +/// ceiling, the audio share of it, and the resolution that ceiling can carry — +/// not just a label. Those numbers are Jellyfin encoding domain vocabulary, so +/// they live here and the frontend only ever names a variant; the labels the +/// picker shows are served over IPC by `player_get_streaming_qualities`. +/// +/// The ladder is deliberately expressed in bandwidth rather than resolution: it +/// exists to fit a connection, and the resolution cap is chosen *from* the +/// bitrate so the encoder does not spend a small budget on pixels it cannot +/// afford. See docs/specs/streaming-bitrate-cap.md. +/// +/// TRACES: UR-074 | DR-160 +#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum StreamingQuality { + /// No client-imposed cap — the server may direct-play the source as-is. + #[default] + Original, + Mbps20, + Mbps10, + Mbps8, + Mbps4, + Mbps2, + Mbps1, + Kbps720, +} + +impl StreamingQuality { + /// The ladder, highest first, for enumerating across the IPC boundary. + pub const ALL: [StreamingQuality; 8] = [ + StreamingQuality::Original, + StreamingQuality::Mbps20, + StreamingQuality::Mbps10, + StreamingQuality::Mbps8, + StreamingQuality::Mbps4, + StreamingQuality::Mbps2, + StreamingQuality::Mbps1, + StreamingQuality::Kbps720, + ]; + + /// Total bits per second the stream may use (video + audio), or `None` for + /// the uncapped `Original`. + /// + /// This is the number that goes to `PlaybackInfo` as `MaxStreamingBitrate` + /// and into the device profile. Sending it there — not just on the transcode + /// URL — is what makes the cap real: a stream the server decides to *direct + /// play* is served at the source file's own bitrate, and no URL parameter + /// afterwards can reduce it. + pub fn max_bitrate(&self) -> Option { + match self { + StreamingQuality::Original => None, + StreamingQuality::Mbps20 => Some(20_000_000), + StreamingQuality::Mbps10 => Some(10_000_000), + StreamingQuality::Mbps8 => Some(8_000_000), + StreamingQuality::Mbps4 => Some(4_000_000), + StreamingQuality::Mbps2 => Some(2_000_000), + StreamingQuality::Mbps1 => Some(1_000_000), + StreamingQuality::Kbps720 => Some(720_000), + } + } + + /// Bits per second allotted to the audio track. + /// + /// The value shrinks with the ladder because at the bottom rungs a fixed + /// 384 kbps would be a third of the entire budget. + pub fn audio_bitrate(&self) -> u64 { + match self { + StreamingQuality::Original + | StreamingQuality::Mbps20 + | StreamingQuality::Mbps10 + | StreamingQuality::Mbps8 => 384_000, + StreamingQuality::Mbps4 => 256_000, + StreamingQuality::Mbps2 => 192_000, + StreamingQuality::Mbps1 => 128_000, + StreamingQuality::Kbps720 => 96_000, + } + } + + /// Bits per second allotted to the video track: the total minus the audio + /// share, so the two together honour [`max_bitrate`](Self::max_bitrate) + /// rather than overshooting it by the size of the audio track. + pub fn video_bitrate(&self) -> Option { + self.max_bitrate() + .map(|total| total.saturating_sub(self.audio_bitrate())) + } + + /// Resolution ceiling that suits the bitrate, or `None` to leave the source + /// resolution alone. Scaling down is what keeps a small budget looking like + /// clean video instead of blocky 1080p. + pub fn max_height(&self) -> Option { + match self { + // 20 Mbps carries 4K, so it caps bandwidth without capping pixels. + StreamingQuality::Original | StreamingQuality::Mbps20 => None, + StreamingQuality::Mbps10 | StreamingQuality::Mbps8 => Some(1080), + StreamingQuality::Mbps4 | StreamingQuality::Mbps2 => Some(720), + StreamingQuality::Mbps1 => Some(480), + StreamingQuality::Kbps720 => Some(360), + } + } + + /// Human label for the picker. Lives in Rust with the numbers it describes, + /// so the two cannot drift apart. + pub fn label(&self) -> &'static str { + match self { + StreamingQuality::Original => "Original", + StreamingQuality::Mbps20 => "20 Mbps", + StreamingQuality::Mbps10 => "10 Mbps", + StreamingQuality::Mbps8 => "8 Mbps", + StreamingQuality::Mbps4 => "4 Mbps", + StreamingQuality::Mbps2 => "2 Mbps", + StreamingQuality::Mbps1 => "1 Mbps", + StreamingQuality::Kbps720 => "720 kbps", + } + } + + /// Secondary line for the picker: what the cap means in practice. + pub fn detail(&self) -> &'static str { + match self { + StreamingQuality::Original => "No limit — highest quality", + StreamingQuality::Mbps20 => "Up to 4K", + StreamingQuality::Mbps10 => "1080p, high quality", + StreamingQuality::Mbps8 => "1080p", + StreamingQuality::Mbps4 => "720p", + StreamingQuality::Mbps2 => "720p, reduced", + StreamingQuality::Mbps1 => "480p", + StreamingQuality::Kbps720 => "360p — slowest connections", + } + } +} + /// Video playback settings #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -159,6 +291,14 @@ pub struct VideoSettings { /// Maximum number of episodes to auto-play consecutively (0 = unlimited) #[serde(default)] pub auto_play_max_episodes: u32, + /// Bandwidth ceiling applied to every video stream. + /// + /// `#[serde(default)]` so settings JSON persisted before this field existed + /// loads as the previous behaviour (uncapped). + /// + /// TRACES: UR-074 | DR-160 + #[serde(default)] + pub streaming_quality: StreamingQuality, } impl Default for VideoSettings { @@ -167,6 +307,7 @@ impl Default for VideoSettings { auto_play_next_episode: true, auto_play_countdown_seconds: 10, auto_play_max_episodes: 0, + streaming_quality: StreamingQuality::Original, } } } @@ -427,12 +568,14 @@ mod tests { auto_play_next_episode: false, auto_play_countdown_seconds: 15, auto_play_max_episodes: 5, + streaming_quality: StreamingQuality::Mbps4, }; let json = serde_json::to_string(&settings).unwrap(); assert!(json.contains("\"autoPlayNextEpisode\":false")); assert!(json.contains("\"autoPlayCountdownSeconds\":15")); assert!(json.contains("\"autoPlayMaxEpisodes\":5")); + assert!(json.contains("\"streamingQuality\":\"mbps4\"")); let parsed: VideoSettings = serde_json::from_str(&json).unwrap(); assert!(!parsed.auto_play_next_episode); @@ -448,5 +591,90 @@ mod tests { assert!(parsed.auto_play_next_episode); assert_eq!(parsed.auto_play_countdown_seconds, 10); assert_eq!(parsed.auto_play_max_episodes, 0); + // Settings persisted before the cap existed must load as uncapped — + // inventing a limit for an upgrading user would silently degrade their + // picture with no setting having been changed. + assert_eq!(parsed.streaming_quality, StreamingQuality::Original); + } + + /// The whole point of a step is the number of bits it promises not to + /// exceed, so video + audio must fit inside the total — a video bitrate set + /// to the full cap would overshoot it by the size of the audio track. + /// + /// TRACES: UR-074 | DR-160 | UT-157 + #[test] + fn test_streaming_quality_budget_is_internally_consistent() { + for quality in StreamingQuality::ALL { + let Some(total) = quality.max_bitrate() else { + assert_eq!( + quality, + StreamingQuality::Original, + "only Original may be uncapped" + ); + assert!(quality.video_bitrate().is_none()); + assert!(quality.max_height().is_none()); + continue; + }; + + let video = quality.video_bitrate().expect("a capped step caps video"); + assert_eq!( + video + quality.audio_bitrate(), + total, + "{:?}: video + audio must equal the cap", + quality + ); + assert!( + video > 0, + "{:?}: audio must not consume the budget", + quality + ); + assert!(!quality.label().is_empty()); + assert!(!quality.detail().is_empty()); + } + } + + /// The ladder is presented to the user as descending, and the resolution cap + /// must fall with it — a lower bitrate paired with a higher resolution would + /// spend the smaller budget on more pixels, which is backwards. + /// + /// TRACES: UR-074 | DR-160 | UT-157 + #[test] + fn test_streaming_quality_ladder_descends() { + let steps = StreamingQuality::ALL; + for pair in steps.windows(2) { + let (higher, lower) = (pair[0], pair[1]); + let higher_bitrate = higher.max_bitrate().unwrap_or(u64::MAX); + let lower_bitrate = lower.max_bitrate().unwrap_or(u64::MAX); + assert!( + higher_bitrate > lower_bitrate, + "{:?} must sit above {:?}", + higher, + lower + ); + assert!( + higher.max_height().unwrap_or(u32::MAX) >= lower.max_height().unwrap_or(u32::MAX), + "{:?} must not cap resolution below {:?}", + higher, + lower + ); + assert!(higher.audio_bitrate() >= lower.audio_bitrate()); + } + } + + /// The persisted form is the serde token, and it must survive a round trip — + /// a rename here silently resets everyone's saved cap to uncapped. + /// + /// TRACES: UR-074 | DR-160 | UT-157 + #[test] + fn test_streaming_quality_round_trips_through_json() { + for quality in StreamingQuality::ALL { + let json = serde_json::to_string(&quality).expect("serialises"); + let parsed: StreamingQuality = serde_json::from_str(&json).expect("parses back"); + assert_eq!(parsed, quality); + } + assert_eq!( + serde_json::to_string(&StreamingQuality::Mbps10).unwrap(), + "\"mbps10\"" + ); } } diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index 6158cdc6..93c10998 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -197,6 +197,40 @@ async playerSetVideoSettings(settings: VideoSettings) : Promise { async playerGetVideoSettings() : Promise { return await TAURI_INVOKE("player_get_video_settings"); }, +/** + * 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-160 + */ +async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string])[]> { + return await TAURI_INVOKE("player_get_streaming_qualities"); +}, +/** + * Change the bandwidth ceiling of the video that is playing *right now*. + * + * A cap is a property of the stream the server is producing, so unlike a volume + * change it cannot be applied to a stream already in flight — the stream has to + * be re-opened at the new quality and resumed at the current position. That is + * the same reload the transcoded-seek and audio-track paths use, and the same + * two-sided split: HTML5 gets the URL back and reloads its own element, while a + * native backend is reloaded here. + * + * The change applies to this playback *and* to everything started afterwards + * (it sets the process-wide ceiling), but it is deliberately **not** persisted: + * the in-player picker is a "this film, this connection" control, and the + * durable default belongs to Settings. `player_set_video_settings` is the one + * that writes to the database. + * + * TRACES: UR-074 | DR-160 + */ +async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise { + return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex }); +}, /** * Set sleep timer mode */ @@ -2857,6 +2891,44 @@ export type StreamKind = "audio" | "video" | "subtitle" | * Any stream kind we do not model explicitly (e.g. embedded image, data). */ "other" +/** + * Response for a mid-playback streaming-quality change. + * + * Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller + * has to reload anything, so no strategy branch lives in the UI. + * + * TRACES: UR-074 | DR-160 + */ +export type StreamQualityResponse = +/** + * The native backend was reloaded here; nothing left for the frontend. + */ +{ strategy: "native"; position: number } | +/** + * HTML5 must reload its element with this URL. + */ +{ strategy: "reloadStream"; new_url: string; position: number } +/** + * A ceiling on how much bandwidth a *video* stream may consume. + * + * A quality step is a bundle of concrete transcode parameters — total stream + * ceiling, the audio share of it, and the resolution that ceiling can carry — + * not just a label. Those numbers are Jellyfin encoding domain vocabulary, so + * they live here and the frontend only ever names a variant; the labels the + * picker shows are served over IPC by `player_get_streaming_qualities`. + * + * The ladder is deliberately expressed in bandwidth rather than resolution: it + * exists to fit a connection, and the resolution cap is chosen *from* the + * bitrate so the encoder does not spend a small budget on pixels it cannot + * afford. See docs/specs/streaming-bitrate-cap.md. + * + * TRACES: UR-074 | DR-160 + */ +export type StreamingQuality = +/** + * No client-imposed cap — the server may direct-play the source as-is. + */ +"original" | "mbps20" | "mbps10" | "mbps8" | "mbps4" | "mbps2" | "mbps1" | "kbps720" /** * Represents a subtitle track * @@ -2978,7 +3050,16 @@ autoPlayCountdownSeconds: number; /** * Maximum number of episodes to auto-play consecutively (0 = unlimited) */ -autoPlayMaxEpisodes?: number } +autoPlayMaxEpisodes?: number; +/** + * Bandwidth ceiling applied to every video stream. + * + * `#[serde(default)]` so settings JSON persisted before this field existed + * loads as the previous behaviour (uncapped). + * + * TRACES: UR-074 | DR-160 + */ +streamingQuality?: StreamingQuality } /** * Volume normalization levels matching Spotify's presets */ diff --git a/src/lib/components/player/VideoPlayer.svelte b/src/lib/components/player/VideoPlayer.svelte index 728a3206..e42ba6f8 100644 --- a/src/lib/components/player/VideoPlayer.svelte +++ b/src/lib/components/player/VideoPlayer.svelte @@ -4,7 +4,7 @@ import { get } from "svelte/store"; import { goto } from "$app/navigation"; import { commands } from "$lib/api/bindings"; - import type { JRayActor } from "$lib/api/bindings"; + import type { JRayActor, StreamingQuality } from "$lib/api/bindings"; import { listen } from "@tauri-apps/api/event"; import Hls from "hls.js"; import type { MediaItem } from "$lib/api/types"; @@ -244,6 +244,14 @@ let showSubtitleMenu = $state(false); let selectedSubtitleIndex = $state(null); + // Streaming bandwidth ceiling. The ladder and the current value both come from + // Rust — the frontend never encodes what a step means. + // TRACES: UR-074 | DR-160 + let showQualityMenu = $state(false); + let streamingQualities = $state<[StreamingQuality, string, string][]>([]); + let selectedQuality = $state("original"); + let changingQuality = $state(false); + // Track duration from video element (for when media item doesn't have runTimeTicks) let videoDuration = $state(0); @@ -640,6 +648,27 @@ }); }); + // Populate the quality menu. Deliberately its own *synchronous* onMount that + // fires the load without awaiting it: an await inside the main onMount below + // flips the component into HTML5 mode and breaks native seeking, and nothing + // about playback waits on this list. + // + // TRACES: UR-074 | DR-160 + onMount(() => { + Promise.all([ + commands.playerGetStreamingQualities(), + commands.playerGetVideoSettings(), + ]) + .then(([qualities, settings]) => { + streamingQualities = qualities; + // Optional on the wire (serde default) — absent means uncapped. + selectedQuality = settings.streamingQuality ?? "original"; + }) + .catch((err) => { + console.warn("[VideoPlayer] Failed to load streaming qualities:", err); + }); + }); + // Set up progress reporting interval onMount(async () => { // Background-audio lifecycle listeners MUST be registered synchronously — @@ -1888,6 +1917,47 @@ } } + function toggleQualityMenu() { + showQualityMenu = !showQualityMenu; + } + + /** + * Re-open the current stream at a different bandwidth ceiling. + * + * The backend owns everything about how that happens — it decides whether the + * caller reloads (HTML5) or it reloads the native backend itself — so this + * only supplies the position to resume at and reverts the selection if the + * switch fails. + * + * TRACES: UR-074 | DR-160 + */ + async function selectQuality(quality: StreamingQuality) { + showQualityMenu = false; + if (quality === selectedQuality || changingQuality) return; + + const previous = selectedQuality; + selectedQuality = quality; + changingQuality = true; + try { + stopTimeUpdates(); + await playerController.setStreamQuality( + quality, + videoElement ? videoElement.currentTime + seekOffset : null, + mediaSourceId ?? null, + selectedAudioTrackIndex + ); + if (videoElement && !videoElement.paused) { + startTimeUpdates(); + } + console.log("[VideoPlayer] Streaming quality changed:", quality); + } catch (err) { + console.error("[VideoPlayer] Failed to change streaming quality:", err); + selectedQuality = previous; + } finally { + changingQuality = false; + } + } + function toggleSubtitleMenu() { showSubtitleMenu = !showSubtitleMenu; } @@ -2284,6 +2354,48 @@ {/if} + + {#if streamingQualities.length > 0} +
+ + + {#if showQualityMenu} +
+
+
+ Quality +
+ {#each streamingQualities as [quality, label, detail]} + + {/each} +
+
+ {/if} +
+ {/if} + {#if subtitleTracks().length > 0}
diff --git a/src/lib/player/index.ts b/src/lib/player/index.ts index c22cb57f..5a1da010 100644 --- a/src/lib/player/index.ts +++ b/src/lib/player/index.ts @@ -21,6 +21,7 @@ import type { PlayTracksContext, PlayAlbumTrackRequest, PlayItemRequest, + StreamingQuality, } from "$lib/api/bindings"; import { auth } from "$lib/stores/auth"; import type { PlayerAdapter } from "./adapters/types"; @@ -182,6 +183,36 @@ async function switchAudioTrack( } } +/** + * Change the bandwidth ceiling of the video playing now. The backend re-opens + * the stream at the new quality and decides who reloads: it handles a native + * backend itself, and hands HTML5 a URL for the same `reloadSource` primitive + * the audio-track switch uses. Requires an active video adapter. + * + * TRACES: UR-074 | DR-160 + */ +async function setStreamQuality( + quality: StreamingQuality, + currentPosition: number | null, + mediaSourceId: string | null, + audioTrackIndex: number | null +): Promise { + const adapter = activeAdapter; + if (!adapter) return; + const response = (await commands.playerSetStreamQuality( + requireHandle(), + quality, + adapter.kind === "html5", + currentPosition, + mediaSourceId, + audioTrackIndex + )) as any; + // Serde keeps these snake_case (only the "strategy" tag is camelCase). + if (response.strategy === "reloadStream") { + await adapter.reloadSource(response.new_url ?? "", response.position ?? currentPosition ?? 0); + } +} + async function next() { await commands.playerNext(); } @@ -300,6 +331,7 @@ export const playerController = { setSubtitleTrack, seekVideo, switchAudioTrack, + setStreamQuality, playTracks, playAlbumTrack, playItem, diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 0e83df42..dd6e6f52 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -6,6 +6,7 @@ AudioSettings, CacheConfig, EqPreset, + StreamingQuality, VideoSettings, VolumeLevel, } from "$lib/api/bindings"; @@ -62,8 +63,14 @@ autoPlayNextEpisode: true, autoPlayCountdownSeconds: 10, autoPlayMaxEpisodes: 0, + streamingQuality: "original", }); + // Bandwidth ceilings offered by the streaming-quality picker, as + // [variant, label, detail] — the numbers behind each step are Jellyfin + // encoding vocabulary, so Rust serves the list. TRACES: UR-074 | DR-160 + let streamingQualities = $state<[StreamingQuality, string, string][]>([]); + // Download/caching behaviour, incl. the WiFi-only gate (UR-053). let cacheConfig = $state({ queuePrecacheEnabled: true, @@ -126,11 +133,12 @@ try { loading = true; networkDetectionSupported = isNetworkDetectionSupported(); - const [audioResult, videoResult, cacheResult, presets] = await Promise.all([ + const [audioResult, videoResult, cacheResult, presets, qualities] = await Promise.all([ commands.playerGetAudioSettings(), commands.playerGetVideoSettings(), getCacheConfig(), commands.playerGetEqPresets(), + commands.playerGetStreamingQualities(), ]); // equalizerBands is optional on the wire (serde default); guarantee a // dense 10-band array so the slider bindings are never undefined. @@ -141,6 +149,7 @@ videoSettings = videoResult; cacheConfig = cacheResult; eqPresets = presets; + streamingQualities = qualities; // Load cache stats in parallel but don't block on it loadCacheStats(); } catch (e) { @@ -331,6 +340,12 @@ persistVideo(); } + /** TRACES: UR-074 | DR-160 */ + function handleStreamingQualityChange(quality: StreamingQuality) { + videoSettings.streamingQuality = quality; + persistVideo(); + } + function handleSmartCachingToggle() { cacheConfig.albumAffinityEnabled = !cacheConfig.albumAffinityEnabled; persistCache(); @@ -681,6 +696,38 @@ {/if}
+ +
+

Streaming Quality

+

+ Limit how much bandwidth video streams may use. Lower settings ask the + server to transcode before sending, which saves data on metered or slow + connections at the cost of picture quality. You can also change this for + a single video from the player's quality menu. +

+
+ {#each streamingQualities as [quality, label, detail]} + + {/each} +
+

+ Applies to videos started from now on; a video already playing keeps the + quality it started at. +

+
+ {#if supportsNativeVideo}