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:
@@ -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