docs(player): allocate UR-074/DR-162 for the streaming bitrate cap
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 16s

The feature shipped tagged against DR-160, which a parallel session had
claimed for picture-in-picture in the meantime. Renumbered to DR-162
across the Rust and frontend TRACES comments (the PiP tags in
VideoPlayer.svelte, pictureInPicture.ts and nativeVideo.ts keep DR-160)
and regenerated bindings.ts.

Adds the requirement rows the tags point at: UR-074 for the user need, and
DR-162 covering why the cap has to reach the PlaybackInfo negotiation and
not only the transcode URL, why the ceiling is process-wide, and why the
Settings default persists while the in-player override does not. Notes
that this gives UR-070 its resume-at-the-same-point mechanism while the
server-offered rendition list that requirement also asks for stays
proposed. UT-156/157 record what the tests pin.

docs/specs/streaming-bitrate-cap.md carries the layer assignment — the
step definitions, the video/audio split, the resolution pairing and the
reload decision are all Rust; the frontend holds a serde token and the
labels it was handed.

TRACES: UR-074 | DR-162 | UT-156, UT-157
This commit is contained in:
2026-08-15 16:39:53 +02:00
parent 9c352fdb77
commit d49d027020
13 changed files with 197 additions and 38 deletions
+5 -5
View File
@@ -205,7 +205,7 @@ async playerGetVideoSettings() : Promise<VideoSettings> {
* frontend reads them here rather than encoding them — the same arrangement as
* [`player_get_eq_presets`].
*
* TRACES: UR-074 | DR-160
* TRACES: UR-074 | DR-162
*/
async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string])[]> {
return await TAURI_INVOKE("player_get_streaming_qualities");
@@ -226,7 +226,7 @@ async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string
* durable default belongs to Settings. `player_set_video_settings` is the one
* that writes to the database.
*
* TRACES: UR-074 | DR-160
* TRACES: UR-074 | DR-162
*/
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 });
@@ -2897,7 +2897,7 @@ export type StreamKind = "audio" | "video" | "subtitle" |
* 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
* TRACES: UR-074 | DR-162
*/
export type StreamQualityResponse =
/**
@@ -2922,7 +2922,7 @@ export type StreamQualityResponse =
* 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
* TRACES: UR-074 | DR-162
*/
export type StreamingQuality =
/**
@@ -3057,7 +3057,7 @@ autoPlayMaxEpisodes?: number;
* `#[serde(default)]` so settings JSON persisted before this field existed
* loads as the previous behaviour (uncapped).
*
* TRACES: UR-074 | DR-160
* TRACES: UR-074 | DR-162
*/
streamingQuality?: StreamingQuality }
/**
@@ -82,6 +82,10 @@ vi.mock("$lib/api/bindings", () => ({
playerCancelSleepTimer: (...a: any[]) => playerCancelSleepTimer(...(a as [])),
playerSetSubtitleTrack: vi.fn(async () => ({})),
playerSwitchAudioTrack: vi.fn(async () => ({})),
// The player loads the streaming-quality picker on mount; without these the
// mock throws and every test in the file fails before it starts.
playerGetStreamingQualities: vi.fn(async () => []),
playerGetVideoSettings: vi.fn(async () => ({ streamingQuality: "original" })),
storageGetSeriesAudioPreference: vi.fn(async () => null),
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
},
+4 -4
View File
@@ -246,7 +246,7 @@
// 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
// TRACES: UR-074 | DR-162
let showQualityMenu = $state(false);
let streamingQualities = $state<[StreamingQuality, string, string][]>([]);
let selectedQuality = $state<StreamingQuality>("original");
@@ -653,7 +653,7 @@
// flips the component into HTML5 mode and breaks native seeking, and nothing
// about playback waits on this list.
//
// TRACES: UR-074 | DR-160
// TRACES: UR-074 | DR-162
onMount(() => {
Promise.all([
commands.playerGetStreamingQualities(),
@@ -1929,7 +1929,7 @@
* only supplies the position to resume at and reverts the selection if the
* switch fails.
*
* TRACES: UR-074 | DR-160
* TRACES: UR-074 | DR-162
*/
async function selectQuality(quality: StreamingQuality) {
showQualityMenu = false;
@@ -2354,7 +2354,7 @@
</div>
{/if}
<!-- Streaming quality (bandwidth ceiling). TRACES: UR-074 | DR-160 -->
<!-- Streaming quality (bandwidth ceiling). TRACES: UR-074 | DR-162 -->
{#if streamingQualities.length > 0}
<div class="relative">
<button
@@ -84,6 +84,10 @@ vi.mock("$lib/api/bindings", () => ({
playerCancelSleepTimer: vi.fn(async () => ({})),
playerSetSubtitleTrack: vi.fn(async () => ({})),
playerSwitchAudioTrack: vi.fn(async () => ({})),
// The player loads the streaming-quality picker on mount; without these the
// mock throws and every test in the file fails before it starts.
playerGetStreamingQualities: vi.fn(async () => []),
playerGetVideoSettings: vi.fn(async () => ({ streamingQuality: "original" })),
storageGetSeriesAudioPreference: vi.fn(async () => null),
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
},
+1 -1
View File
@@ -189,7 +189,7 @@ async function switchAudioTrack(
* 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
* TRACES: UR-074 | DR-162
*/
async function setStreamQuality(
quality: StreamingQuality,
+3 -3
View File
@@ -68,7 +68,7 @@
// 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
// encoding vocabulary, so Rust serves the list. TRACES: UR-074 | DR-162
let streamingQualities = $state<[StreamingQuality, string, string][]>([]);
// Download/caching behaviour, incl. the WiFi-only gate (UR-053).
@@ -340,7 +340,7 @@
persistVideo();
}
/** TRACES: UR-074 | DR-160 */
/** TRACES: UR-074 | DR-162 */
function handleStreamingQualityChange(quality: StreamingQuality) {
videoSettings.streamingQuality = quality;
persistVideo();
@@ -698,7 +698,7 @@
<!-- Streaming quality: the bandwidth ceiling every video stream is
opened against. The steps and their labels come from Rust.
TRACES: UR-074 | DR-160 -->
TRACES: UR-074 | DR-162 -->
<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">