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:
2026-08-15 16:34:56 +02:00
parent 9f5f57cba4
commit dda2ff86a3
9 changed files with 971 additions and 16 deletions
+48 -1
View File
@@ -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}