feat(player): cap streaming bandwidth with a user-chosen bitrate ceiling
Video streams were opened at a fixed allowance nobody could change: MaxStreamingBitrate=20000000/VideoBitrate=18000000 on the HLS transcode URL, 20 Mbps in the PlaybackInfo negotiation, and a 999999999 device profile that let the server direct-play a source of any size. On a metered or slow connection there was no way to spend less. StreamingQuality is a ladder of bandwidth ceilings — Original, 20/10/8/ 4/2/1 Mbps and 720 kbps — where a step bundles the total ceiling, the audio share of it and the resolution that budget can carry. Those numbers are Jellyfin encoding vocabulary, so they live in Rust and the frontend only names a variant; labels and details come back over IPC from player_get_streaming_qualities, the same arrangement as the EQ presets. The cap has to reach the *negotiation*, not just the transcode URL: max_static_bitrate in the device profile is what makes the server refuse to direct-play a file fatter than the cap, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot. So it is applied at all four places that decide bandwidth — the HLS URL builder, PlaybackInfo, the Live TV stream, and the background-audio handoff (which takes the lower of the cap and its own 384 kbps). Video bitrate is the total minus the audio share so the two together honour the ceiling rather than overshooting it. The ceiling is process-wide rather than a repository field: it is a preference about this device's connection, must survive a repository rebuilt on re-login, and every URL builder plus the negotiation have to agree on it or the cap leaks. Same shape as INCLUDE_CATALOG_BROWSE. Two ways in. Settings holds the durable default, persisted to app_settings and restored at startup — unlike the rest of VideoSettings, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to see. The in-player menu is the "this film, this connection" override: a cap is a property of the stream the server is producing, so it cannot apply to one already in flight — player_set_stream_quality re-opens the stream at the new quality and resumes at the current position, reloading the native backend itself and handing HTML5 a URL for the same reloadSource primitive the audio-track switch uses. Tests pin the URL parameters at a capped and an uncapped step, the handoff taking the lower of the two, the ladder's internal consistency (video + audio == cap, resolution descending with bitrate) and the persisted token's round trip. The ceiling is process-wide, so the tests that depend on it serialise on a guard that restores the default. TRACES: UR-074 | DR-160 | UT-156, UT-157
This commit is contained in:
@@ -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<f64>,
|
||||
media_source_id: Option<String>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<StreamQualityResponse, String> {
|
||||
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(
|
||||
|
||||
@@ -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<VideoSettings, String> {
|
||||
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<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-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::<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(
|
||||
|
||||
@@ -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<tauri::Wry> {
|
||||
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") {
|
||||
|
||||
@@ -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<StreamingQuality> = 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
|
||||
|
||||
@@ -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<u64> {
|
||||
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<u64> {
|
||||
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<u32> {
|
||||
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\""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+82
-1
@@ -197,6 +197,40 @@ async playerSetVideoSettings(settings: VideoSettings) : Promise<VideoSettings> {
|
||||
async playerGetVideoSettings() : Promise<VideoSettings> {
|
||||
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<StreamQualityResponse> {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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<number | null>(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<StreamingQuality>("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 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Streaming quality (bandwidth ceiling). TRACES: UR-074 | DR-160 -->
|
||||
{#if streamingQualities.length > 0}
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={toggleQualityMenu}
|
||||
class="text-white hover:text-gray-300 disabled:opacity-50"
|
||||
disabled={changingQuality}
|
||||
aria-label="Select streaming quality"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showQualityMenu}
|
||||
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto">
|
||||
<div class="p-2">
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Quality
|
||||
</div>
|
||||
{#each streamingQualities as [quality, label, detail]}
|
||||
<button
|
||||
onclick={() => selectQuality(quality)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality === quality ? 'bg-white/20' : ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">{label}</span>
|
||||
<span class="text-xs text-gray-400">{detail}</span>
|
||||
</div>
|
||||
{#if selectedQuality === quality}
|
||||
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Subtitle Selection -->
|
||||
{#if subtitleTracks().length > 0}
|
||||
<div class="relative">
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
|
||||
@@ -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<CacheConfig>({
|
||||
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}
|
||||
</div>
|
||||
|
||||
<!-- Streaming quality: the bandwidth ceiling every video stream is
|
||||
opened against. The steps and their labels come from Rust.
|
||||
TRACES: UR-074 | DR-160 -->
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6 mt-4">
|
||||
<h3 class="text-xl font-semibold text-white">Streaming Quality</h3>
|
||||
<p class="text-sm text-gray-400 mt-1 mb-4">
|
||||
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.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{#each streamingQualities as [quality, label, detail]}
|
||||
<button
|
||||
onclick={() => handleStreamingQualityChange(quality)}
|
||||
class="py-3 px-3 rounded-lg transition-all text-left
|
||||
{videoSettings.streamingQuality === quality
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
||||
aria-pressed={videoSettings.streamingQuality === quality}
|
||||
>
|
||||
<div class="font-semibold text-sm">{label}</div>
|
||||
<div class="text-xs opacity-75 mt-0.5">{detail}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-3">
|
||||
Applies to videos started from now on; a video already playing keeps the
|
||||
quality it started at.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Native video (experimental). Only rendered where the platform's Rust
|
||||
backend actually has a native video surface (Android). -->
|
||||
{#if supportsNativeVideo}
|
||||
|
||||
Reference in New Issue
Block a user