feat(settings): split web quality picker into preferred quality and max bitrate

The web Playback screen compressed playback.preferred_quality and
playback.max_bitrate_kbps into one preset picker while the device screen
already edited them as two controls, so a device override never read as an
override of anything the user could see. Replace the preset picker with the
same two rows, backed by a shared bandwidth ladder in lib/bitrateOptions
(rungs widen above 20 Mbps up to the contract's 200 Mbps ceiling; "No limit"
clears the rows rather than storing a sentinel). qualityPresets.ts is retired
with its test.

The bitrate cap now also does something on web: WatchPlaybackChrome reads it
alongside the resolution cap and the player keeps the HLS startup tier under
it, composing with the resolution preference. A direct-play base is not
restarted to enforce it, and an explicit in-player pick still wins.

Verified against a live backend: writes land as typed JSON at profile scope,
No limit deletes the row, and stored off-ladder values stay selectable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-08-02 01:49:41 +00:00
co-authored by Claude Fable 5
parent c6383aa4dd
commit 7aee29be7b
13 changed files with 374 additions and 329 deletions
@@ -15,7 +15,7 @@ import { SettingsGroup } from "@/components/settings/SettingsGroup";
import { groupDeviceSettings } from "@/lib/deviceSettingGroups";
import type { EffectiveSetting } from "@/hooks/queries/settingValues";
import { SETTING_DEFINITIONS, type SettingKey } from "@/lib/settingsContract";
import { formatQualityBitrate } from "@/player/hooks/useTranscodeQuality";
import { bitrateSelectChoices } from "@/lib/bitrateOptions";
import { namedLanguageOptionsFor } from "@/lib/languageOptions";
import { controlKindFor, optionsFor } from "@/lib/settingsDisplay";
import { cn } from "@/lib/utils";
@@ -388,25 +388,9 @@ function permittedOptions(settingKey: SettingKey, effective: EffectiveSetting |
*
* Returns null for any select the manifest actually gives members, which is
* every other one — this exists only for a numeric range declared with a
* select control.
* select control. The ladder itself lives in lib/bitrateOptions so the
* profile Defaults screen offers the same choices.
*/
/**
* The bandwidth ladder, in kbps.
*
* The low end matches the in-player quality switcher
* (web/src/player/hooks/useTranscodeQuality.ts) so a cap chosen here lines up
* with what the player offers mid-playback. Above that it keeps climbing to
* the definition's own ceiling of 200 Mbps, which is what remuxed 4K HDR and
* untouched Blu-ray rips actually need — a ladder that stopped short would cap
* people below what their server can already send them.
*
* Entries outside a definition's declared range are filtered out, so this list
* can cover more ground than any single setting allows.
*/
const BITRATE_CHOICES_KBPS = [
1500, 2000, 4000, 6000, 10000, 15000, 20000, 30000, 40000, 60000, 80000, 100000, 150000, 200000,
];
function numericSelectChoices(
settingKey: SettingKey,
definition: (typeof SETTING_DEFINITIONS)[SettingKey],
@@ -417,29 +401,8 @@ function numericSelectChoices(
const hasMembers = options.some((option) => option.value !== "");
if (!isNumeric || hasMembers) return null;
const min = definition.minimum ?? 0;
const max = definition.maximum ?? Number.MAX_SAFE_INTEGER;
const choices = BITRATE_CHOICES_KBPS.filter((kbps) => kbps >= min && kbps <= max).map((kbps) => ({
value: String(kbps),
label: formatBitrate(kbps),
}));
// A value set elsewhere (an API call, another client) must stay selectable
// rather than silently reading as "No limit".
if (currentValue !== "" && !choices.some((choice) => choice.value === currentValue)) {
const parsed = Number(currentValue);
if (Number.isFinite(parsed)) {
choices.push({ value: currentValue, label: formatBitrate(parsed) });
choices.sort((a, b) => Number(a.value) - Number(b.value));
}
}
const unsetLabel = settingKey === "playback.max_bitrate_kbps" ? "No limit" : "Unset";
return [{ value: "", label: unsetLabel }, ...choices];
}
function formatBitrate(kbps: number): string {
return formatQualityBitrate(kbps);
return bitrateSelectChoices(definition, currentValue, unsetLabel);
}
/** Selects edit strings; integers travel back as numbers. */
+59
View File
@@ -0,0 +1,59 @@
import type { SettingDefinition } from "@/lib/settingsContract";
/**
* Bandwidth choices for `playback.max_bitrate_kbps`, shared by every surface
* that edits it — the profile Defaults screen and the per-device screen — so
* the same stored value always reads back as the same choice.
*
* The low end matches the in-player quality switcher
* (web/src/player/hooks/useTranscodeQuality.ts) so a cap chosen here lines up
* with what the player offers mid-playback. Above 20 Mbps the rungs widen —
* nobody is fine-tuning between 30 and 40 Mbps, they are picking a rough
* ceiling for a remote box — up to the definition's own ceiling of 200 Mbps,
* which is what remuxed 4K HDR and untouched Blu-ray rips actually need. The
* rung above 200 is "No limit", which clears the value rather than storing a
* bigger number.
*
* Entries outside a definition's declared range are filtered out, so this list
* can cover more ground than any single setting allows.
*/
export const BITRATE_CHOICES_KBPS = [
1500, 2000, 4000, 6000, 10000, 15000, 20000, 40000, 60000, 100000, 200000,
];
export function formatBitrateKbps(kbps: number): string {
if (kbps >= 1000) {
const mbps = kbps / 1000;
return mbps % 1 === 0 ? `${mbps} Mbps` : `${mbps.toFixed(1)} Mbps`;
}
return `${kbps} kbps`;
}
/**
* The select entries for a bandwidth cap: an unset entry, the ladder bounded
* by the definition's own range, and — when the stored value came from
* somewhere else (an API call, another client) — that value itself, so it
* stays selectable rather than silently reading as unset.
*/
export function bitrateSelectChoices(
definition: SettingDefinition,
currentValue: string,
unsetLabel: string,
): { value: string; label: string }[] {
const min = definition.minimum ?? 0;
const max = definition.maximum ?? Number.MAX_SAFE_INTEGER;
const choices = BITRATE_CHOICES_KBPS.filter((kbps) => kbps >= min && kbps <= max).map((kbps) => ({
value: String(kbps),
label: formatBitrateKbps(kbps),
}));
if (currentValue !== "" && !choices.some((choice) => choice.value === currentValue)) {
const parsed = Number(currentValue);
if (Number.isFinite(parsed)) {
choices.push({ value: currentValue, label: formatBitrateKbps(parsed) });
choices.sort((a, b) => Number(a.value) - Number(b.value));
}
}
return [{ value: "", label: unsetLabel }, ...choices];
}
-87
View File
@@ -1,87 +0,0 @@
import { describe, expect, it } from "vitest";
import { SETTING_DEFINITIONS } from "./settingsContract";
import { describeQuality, presetById, presetIdFor, QUALITY_PRESETS } from "./qualityPresets";
describe("quality presets", () => {
it("only composes values the contract accepts", () => {
// The presets are a client-side convenience over two server settings. If one
// named a resolution the enum does not have, or a bitrate outside the
// declared bounds, picking it would 400 at the moment of saving.
const resolutions = new Set(
(SETTING_DEFINITIONS["playback.preferred_quality"].values ?? []).map(
(member) => member.value,
),
);
const bitrate = SETTING_DEFINITIONS["playback.max_bitrate_kbps"];
for (const preset of QUALITY_PRESETS) {
expect(resolutions, `${preset.id} resolution`).toContain(preset.resolution);
if (preset.bitrateKbps !== null) {
expect(preset.bitrateKbps, `${preset.id} bitrate`).toBeGreaterThanOrEqual(
bitrate.minimum ?? 0,
);
expect(preset.bitrateKbps, `${preset.id} bitrate`).toBeLessThanOrEqual(
bitrate.maximum ?? Number.MAX_SAFE_INTEGER,
);
}
}
});
it("has no duplicate ids or duplicate axis pairs", () => {
const ids = new Set(QUALITY_PRESETS.map((preset) => preset.id));
expect(ids.size).toBe(QUALITY_PRESETS.length);
// Two presets resolving to the same pair would make the picker ambiguous:
// whichever matched first would win on read and the other could never
// display as selected.
const pairs = new Set(
QUALITY_PRESETS.map((preset) => `${preset.resolution}|${preset.bitrateKbps}`),
);
expect(pairs.size).toBe(QUALITY_PRESETS.length);
});
it("round-trips every preset through the stored pair", () => {
for (const preset of QUALITY_PRESETS) {
expect(presetIdFor(preset.resolution, preset.bitrateKbps)).toBe(preset.id);
expect(presetById(preset.id)).toEqual(preset);
}
});
it("treats a missing bitrate as uncapped rather than unmatched", () => {
// The server omits a null value, so undefined and null both arrive here.
expect(presetIdFor("auto", null)).toBe("auto");
expect(presetIdFor("auto", undefined)).toBe("auto");
expect(presetIdFor("2160p", undefined)).toBe("2160p");
});
it("describes combinations no preset covers", () => {
// Reachable two ways: someone set the axes independently through the API, or
// the migration decomposed a legacy value whose bitrate is not on this
// ladder. Either way the picker has to say something true.
expect(presetIdFor("1080p", 4500)).toBeNull();
expect(describeQuality("1080p", 4500)).toBe("1080p at 4.5 Mbps");
expect(describeQuality("720p", 3000)).toBe("720p at 3 Mbps");
expect(describeQuality("2160p", 25000)).toBe("4K at 25 Mbps");
});
it("describes the sentinels without inventing a bitrate", () => {
expect(describeQuality("auto", null)).toBe("Auto");
expect(describeQuality("original", null)).toBe("Original");
expect(describeQuality(null, null)).toBe("Auto");
});
it("covers the legacy ladder the migration decomposes", () => {
// Every compound value the server used to accept now maps to a pair. These
// are the pairs internal/settingsmigrate writes, so a user who had one of
// them should open the picker and see a named preset, not "custom".
for (const [resolution, bitrate, label] of [
["1080p", 10000, "1080p High"],
["1080p", 6000, "1080p"],
["720p", 4000, "720p High"],
["720p", 2000, "720p"],
["480p", 1500, "480p"],
] as const) {
expect(describeQuality(resolution, bitrate)).toBe(label);
}
});
});
-136
View File
@@ -1,136 +0,0 @@
/**
* The Quality picker's presets.
*
* The server stores two orthogonal values — `playback.preferred_quality` (a
* resolution cap) and `playback.max_bitrate_kbps` (a bandwidth cap, null for
* uncapped). This file composes them into the single list a user picks from.
*
* Presets live here rather than in the contract on purpose. Baking "high" into
* an enum member would freeze what it means: retuning 1080p High from 10 to 12
* Mbps would be a contract change every client has to agree to, and every
* client would still have to decompose the compound value before sending it.
* As a client-side table it is a one-line edit, and older servers keep working
* because they only ever see the two axes they already understand.
*
* The bitrates match the ladder the in-player switcher already used
* (web/src/player/hooks/useTranscodeQuality.ts), so a preset chosen here lines
* up with what the player offers mid-playback.
*/
export interface QualityPreset {
id: string;
label: string;
description: string;
/** null means "let the server decide", matching the contract's auto member. */
resolution: "auto" | "original" | "2160p" | "1080p" | "720p" | "480p";
/** null is uncapped. */
bitrateKbps: number | null;
}
export const QUALITY_PRESETS: readonly QualityPreset[] = [
{
id: "auto",
label: "Auto",
description: "Silo picks based on your connection.",
resolution: "auto",
bitrateKbps: null,
},
{
id: "original",
label: "Original",
description: "Never transcode. Needs bandwidth to match the file.",
resolution: "original",
bitrateKbps: null,
},
{
id: "2160p",
label: "4K",
description: "Up to 2160p.",
resolution: "2160p",
bitrateKbps: null,
},
{
id: "1080p-high",
label: "1080p High",
description: "1080p at up to 10 Mbps.",
resolution: "1080p",
bitrateKbps: 10000,
},
{
id: "1080p",
label: "1080p",
description: "1080p at up to 6 Mbps.",
resolution: "1080p",
bitrateKbps: 6000,
},
{
id: "1080p-low",
label: "1080p Low",
description: "1080p at up to 3 Mbps, for a slower link.",
resolution: "1080p",
bitrateKbps: 3000,
},
{
id: "720p-high",
label: "720p High",
description: "720p at up to 4 Mbps.",
resolution: "720p",
bitrateKbps: 4000,
},
{
id: "720p",
label: "720p",
description: "720p at up to 2 Mbps.",
resolution: "720p",
bitrateKbps: 2000,
},
{
id: "480p",
label: "480p",
description: "480p at up to 1.5 Mbps, for the tightest connections.",
resolution: "480p",
bitrateKbps: 1500,
},
] as const;
/** The preset id for a stored (resolution, bitrate) pair, or null for a custom combination. */
export function presetIdFor(
resolution: string | null | undefined,
bitrateKbps: number | null | undefined,
): string | null {
const normalizedBitrate = bitrateKbps ?? null;
const match = QUALITY_PRESETS.find(
(preset) => preset.resolution === resolution && preset.bitrateKbps === normalizedBitrate,
);
return match?.id ?? null;
}
export function presetById(id: string): QualityPreset | undefined {
return QUALITY_PRESETS.find((preset) => preset.id === id);
}
/**
* A label for any stored pair, including combinations no preset covers —
* someone who set the two axes independently through the API, or whose values
* came from a legacy compound value the migration decomposed.
*/
export function describeQuality(
resolution: string | null | undefined,
bitrateKbps: number | null | undefined,
): string {
const preset = presetIdFor(resolution, bitrateKbps);
if (preset) return presetById(preset)!.label;
const resolutionLabel =
resolution === "auto" || !resolution
? "Auto"
: resolution === "original"
? "Original"
: resolution === "2160p"
? "4K"
: resolution;
if (bitrateKbps == null) return resolutionLabel;
const mbps = bitrateKbps / 1000;
const rounded = Number.isInteger(mbps) ? String(mbps) : mbps.toFixed(1);
return `${resolutionLabel} at ${rounded} Mbps`;
}
+6 -3
View File
@@ -67,6 +67,8 @@ const NAV_SECTIONS: NavSection[] = [
description: "Quality, language, and skipping",
keywords: [
"video quality",
"bitrate",
"bandwidth",
"spoken language",
"metadata language",
"auto skip",
@@ -75,7 +77,8 @@ const NAV_SECTIONS: NavSection[] = [
"preview",
],
settings: settingIndex(
"Video quality",
"Preferred quality",
"Maximum bitrate",
"Spoken language",
"Metadata language",
"Auto-skip intros",
@@ -310,8 +313,8 @@ const NAV_SECTIONS: NavSection[] = [
"lip sync",
],
settings: settingIndex(
"Video quality",
"Data use limit",
"Preferred quality",
"Maximum bitrate",
"HDR",
"Dolby Vision",
"Play Dolby Vision films as HDR10",
@@ -1,8 +1,25 @@
// @vitest-environment jsdom
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Radix Select reads element sizes via ResizeObserver and opens through
// pointer capture; jsdom provides neither.
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
if (typeof globalThis.ResizeObserver === "undefined") {
(globalThis as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver =
ResizeObserverStub;
}
if (typeof window !== "undefined" && !window.HTMLElement.prototype.hasPointerCapture) {
window.HTMLElement.prototype.hasPointerCapture = () => false;
window.HTMLElement.prototype.scrollIntoView = () => {};
}
import type { EffectiveSetting, EffectiveSettingsMap } from "@/hooks/queries/settingValues";
import { SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
@@ -199,4 +216,81 @@ describe("PlaybackSettings", () => {
identity: { scope: "profile" },
});
});
// Quality is the same two axes the device screen edits — a resolution cap
// and a bandwidth cap — not a compound preset only this screen understands.
it("offers the resolution cap and the bandwidth cap as separate controls", () => {
render(<PlaybackSettings />);
expect(screen.getByText("Preferred quality")).toBeTruthy();
expect(screen.getByText("Maximum bitrate")).toBeTruthy();
expect(screen.queryByText("Video quality")).toBeNull();
});
it("saves the resolution cap as its own key", async () => {
render(<PlaybackSettings />);
await userEvent.click(screen.getByRole("combobox", { name: "Preferred quality" }));
await userEvent.click(await screen.findByRole("option", { name: "1080p" }));
await waitFor(() =>
expect(mutateAsync).toHaveBeenCalledWith({
key: SETTING_KEYS.PLAYBACK_PREFERRED_QUALITY,
value: "1080p",
identity: { scope: "profile" },
}),
);
});
it("saves the bandwidth cap as a number", async () => {
render(<PlaybackSettings />);
await userEvent.click(screen.getByRole("combobox", { name: "Maximum bitrate" }));
await userEvent.click(await screen.findByRole("option", { name: "10 Mbps" }));
await waitFor(() =>
expect(mutateAsync).toHaveBeenCalledWith({
key: SETTING_KEYS.PLAYBACK_MAX_BITRATE_KBPS,
value: 10000,
identity: { scope: "profile" },
}),
);
});
it("clears the bandwidth cap rather than storing a sentinel for No limit", async () => {
mocks.useEffectiveSettings.mockReturnValue({
data: resolved(SETTING_KEYS.PLAYBACK_MAX_BITRATE_KBPS, 6000, "profile"),
isLoading: false,
});
render(<PlaybackSettings />);
await userEvent.click(screen.getByRole("combobox", { name: "Maximum bitrate" }));
await userEvent.click(await screen.findByRole("option", { name: "No limit" }));
// "No cap" is the absence of a value at every layer, so choosing it
// deletes the profile row (and any device row) instead of writing one.
await waitFor(() =>
expect(clearMutateAsync).toHaveBeenCalledWith({
key: SETTING_KEYS.PLAYBACK_MAX_BITRATE_KBPS,
identity: { scope: "profile" },
}),
);
expect(mutateAsync).not.toHaveBeenCalled();
});
it("keeps a bandwidth cap set elsewhere selectable", async () => {
// A value from another client or the API that is not on the ladder must
// read back as itself, not silently as "No limit".
mocks.useEffectiveSettings.mockReturnValue({
data: resolved(SETTING_KEYS.PLAYBACK_MAX_BITRATE_KBPS, 12345, "profile"),
isLoading: false,
});
render(<PlaybackSettings />);
expect(screen.getByRole("combobox", { name: "Maximum bitrate" }).textContent).toContain(
"12.3 Mbps",
);
});
});
+80 -57
View File
@@ -18,7 +18,7 @@ import {
type MetadataLanguageOverrides,
} from "@/lib/metadataLanguagePreferences";
import { SETTING_DEFINITIONS, SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
import { QUALITY_PRESETS, describeQuality, presetById, presetIdFor } from "@/lib/qualityPresets";
import { bitrateSelectChoices } from "@/lib/bitrateOptions";
import { useEffectiveSettings } from "@/hooks/queries/settingValues";
import { useAutoPlayNextSetting } from "@/hooks/queries/autoPlayNext";
import { useProfileDefaultWriter } from "@/hooks/queries/profileDefaults";
@@ -50,80 +50,103 @@ const PLAYBACK_KEYS: SettingKey[] = [
const NEXT_UP_MODES = optionsFor(SETTING_DEFINITIONS[SETTING_KEYS.UI_NEXT_UP_MODE]);
// Radix Select cannot represent "" as an item value, so the unset entry needs
// a sentinel that never collides with a stored bitrate.
const NO_BITRATE_LIMIT = "__no_limit__";
/**
* Quality is two settings behind one picker.
* Quality is the same two settings every other surface edits.
*
* The server stores a resolution cap and a bandwidth cap independently, which
* is what the player has always sent on the wire. Presenting them as one list
* keeps the choice simple while leaving the two axes free: a preset is a
* client-side pairing, so retuning what "High" means never needs the server to
* agree, and someone who sets the axes separately through the API still gets a
* truthful label rather than a picker showing the wrong entry.
* The server stores a resolution cap and a bandwidth cap independently, and
* the device screen already presents them as two controls. Offering the same
* two rows here means a device override reads as an override of something the
* user can see, rather than of half a compound preset — and combinations no
* preset covered ("Original but capped at 40 Mbps" for a remote box) become
* expressible instead of rendering as a disabled "custom" entry.
*/
function QualitySetting() {
const { data: effective } = useEffectiveSettings({
keys: ["playback.preferred_quality", "playback.max_bitrate_kbps"],
keys: [SETTING_KEYS.PLAYBACK_PREFERRED_QUALITY, SETTING_KEYS.PLAYBACK_MAX_BITRATE_KBPS],
});
const { save, reset, isSaving } = useProfileDefaultWriter(effective);
const resolution = effective?.["playback.preferred_quality"]?.value as string | undefined;
const bitrate = effective?.["playback.max_bitrate_kbps"]?.value as number | null | undefined;
const selected = presetIdFor(resolution, bitrate);
const qualityDefinition = SETTING_DEFINITIONS[SETTING_KEYS.PLAYBACK_PREFERRED_QUALITY];
const bitrateDefinition = SETTING_DEFINITIONS[SETTING_KEYS.PLAYBACK_MAX_BITRATE_KBPS];
const apply = (presetId: string) => {
const preset = presetById(presetId);
if (!preset) return;
save("playback.preferred_quality", preset.resolution).catch(() =>
toast.error("Failed to save video quality"),
);
// An uncapped preset clears the bitrate rather than storing a sentinel, so
// "no cap" stays the absence of a value at every layer. Both axes are
// device-overridable, so the reset clears the device row too — otherwise
// an override would keep capping playback after the picker said it did not.
if (preset.bitrateKbps === null) {
reset("playback.max_bitrate_kbps").catch(() =>
toast.error("Failed to clear the bitrate cap"),
);
} else {
save("playback.max_bitrate_kbps", preset.bitrateKbps).catch(() =>
toast.error("Failed to save maximum bitrate"),
);
}
};
const pending = isSaving;
const resolution = (effective?.[SETTING_KEYS.PLAYBACK_PREFERRED_QUALITY]?.value ??
qualityDefinition.defaultValue) as string;
const bitrate = effective?.[SETTING_KEYS.PLAYBACK_MAX_BITRATE_KBPS]?.value as
| number
| null
| undefined;
const bitrateValue = bitrate == null ? "" : String(bitrate);
const bitrateChoices = bitrateSelectChoices(bitrateDefinition, bitrateValue, "No limit");
return (
<SettingRow
label="Video quality"
description="Choose the quality your profile should request when playback begins."
control={(id) => (
<div className="w-full">
<Select value={selected ?? "__custom"} onValueChange={apply}>
<SelectTrigger id={id} className="w-full sm:w-[260px]" disabled={pending}>
<>
<SettingRow
label="Preferred quality"
description="The resolution your profile should request when playback begins. Auto lets Silo pick based on your connection."
control={(id) => (
<Select
value={resolution}
disabled={isSaving}
onValueChange={(value) =>
save(SETTING_KEYS.PLAYBACK_PREFERRED_QUALITY, value).catch(() =>
toast.error("Failed to save preferred quality"),
)
}
>
<SelectTrigger id={id} className="w-full sm:w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{selected === null && (
<SelectItem value="__custom" disabled>
{describeQuality(resolution, bitrate)}
</SelectItem>
)}
{QUALITY_PRESETS.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.label}
{optionsFor(qualityDefinition).map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1.5 text-xs">
{describeQuality(resolution, bitrate)}
</p>
</div>
)}
/>
)}
/>
<SettingRow
label="Maximum bitrate"
description="Cap how much bandwidth playback may use. No limit means Silo picks for the chosen resolution."
control={(id) => (
<Select
value={bitrateValue === "" ? NO_BITRATE_LIMIT : bitrateValue}
disabled={isSaving}
onValueChange={(next) => {
// "No limit" clears the rows rather than storing a sentinel, so
// "no cap" stays the absence of a value at every layer. The reset
// clears the device row too — otherwise an override would keep
// capping playback after this control said it did not.
const request =
next === NO_BITRATE_LIMIT
? reset(SETTING_KEYS.PLAYBACK_MAX_BITRATE_KBPS)
: save(SETTING_KEYS.PLAYBACK_MAX_BITRATE_KBPS, Number(next));
request.catch(() => toast.error("Failed to save maximum bitrate"));
}}
>
<SelectTrigger id={id} className="w-full sm:w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{bitrateChoices.map((choice) => (
<SelectItem
key={choice.value || NO_BITRATE_LIMIT}
value={choice.value === "" ? NO_BITRATE_LIMIT : choice.value}
>
{choice.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</>
);
}
+6
View File
@@ -406,6 +406,9 @@ export function WatchPlaybackHost() {
// The resolution cap, which the quality picker writes canonically and
// no longer mirrors into the profile column playback used to read.
SETTING_KEYS.PLAYBACK_PREFERRED_QUALITY,
// The bandwidth cap that pairs with it; the player keeps its startup
// tier under this so the setting does what its label says.
SETTING_KEYS.PLAYBACK_MAX_BITRATE_KBPS,
],
});
const request = state.request;
@@ -850,6 +853,8 @@ export function WatchPlaybackHost() {
const canonicalQuality = effectivePlaybackSettings?.[SETTING_KEYS.PLAYBACK_PREFERRED_QUALITY]
?.value as string | undefined;
const maxBitrateKbps = effectivePlaybackSettings?.[SETTING_KEYS.PLAYBACK_MAX_BITRATE_KBPS]
?.value as number | null | undefined;
const watchPageProps = buildWatchPageProps({
request: activeRequest,
item: activeItem,
@@ -891,6 +896,7 @@ export function WatchPlaybackHost() {
{(isForeground || isPostRoll) && <WatchPlaybackTitle title={activeItem.title} />}
<WatchPage
{...watchPageProps}
maxBitrateKbps={maxBitrateKbps ?? null}
autoSkipIntro={autoSkipIntro}
autoSkipRecap={autoSkipRecap}
autoPlayNextPreview={autoPlayNextPreview}
@@ -105,6 +105,8 @@ interface VideoPlayerProps {
seriesContext?: SeriesContext;
onNavigateEpisode?: (contentId: string) => void;
qualityPreference?: string | null;
/** Bandwidth cap in kbps from playback.max_bitrate_kbps; null/undefined is uncapped. */
maxBitrateKbps?: number | null;
onRefreshSubtitles?: () => void;
audioTracks?: PlayerAudioTrack[];
activeAudioIndex?: number;
@@ -195,6 +197,7 @@ export function VideoPlayer({
seriesContext,
onNavigateEpisode,
qualityPreference,
maxBitrateKbps,
onRefreshSubtitles,
audioTracks = [],
activeAudioIndex = 0,
@@ -340,6 +343,7 @@ export function VideoPlayer({
playMethod,
initialPosition,
qualityPreference,
maxBitrateKbps,
transportRestart,
});
const { cancelPendingTranscodeStart, startupGeneration } = transcodeQuality;
+2
View File
@@ -70,6 +70,7 @@ export function WatchPage({
initialPosition,
forceInitialPosition,
qualityPreference,
maxBitrateKbps,
explicitAudioTrackIndex,
preferredSubtitleLanguage,
preferredSubtitleTrackSignature,
@@ -480,6 +481,7 @@ export function WatchPage({
}
duration={selectedDuration}
qualityPreference={qualityPreference}
maxBitrateKbps={maxBitrateKbps}
seriesContext={seriesContext}
onNavigateEpisode={onNavigateEpisode}
displayMode={displayMode}
@@ -265,4 +265,73 @@ describe("useTranscodeQuality", () => {
expect(sentBodies()[2]!.subtitle_track_index).toBe(3);
expect(sentBodies()[2]!.subtitle_burn_in).toBe(true);
});
// playback.max_bitrate_kbps. The setting is a promise about bandwidth; the
// startup tier is where the web player keeps it.
it("starts below the bandwidth cap instead of at the file's own bitrate", async () => {
// A remux would auto-start "original" (codec copy at the file's 8 Mbps);
// a 4 Mbps cap must pull the startup down to a tier that fits.
renderHook(
() =>
useTranscodeQuality({
sessionId: "sess-1",
selectedVersion: version,
versions: [version],
playMethod: "remux",
initialPosition: 0,
maxBitrateKbps: 4000,
}),
{ wrapper },
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const body = sentBodies()[0]!;
expect(body.target_codec_video).toBe("h264");
expect(body.target_bitrate_kbps).toBeLessThanOrEqual(4000);
expect(body.target_bitrate_kbps).toBeGreaterThan(0);
});
it("leaves the startup tier alone when it already fits the cap", async () => {
renderHook(
() =>
useTranscodeQuality({
sessionId: "sess-1",
selectedVersion: version,
versions: [version],
playMethod: "remux",
initialPosition: 0,
maxBitrateKbps: 20000,
}),
{ wrapper },
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
// The file's 8 Mbps fits under 20 Mbps, so the remux keeps codec copy.
const body = sentBodies()[0]!;
expect(body.target_codec_video).toBe("copy");
expect(body.target_bitrate_kbps).toBe(0);
});
it("combines a resolution preference with the bandwidth cap", async () => {
// 720p preference offers the 4 Mbps "720p High" tier first; a 2 Mbps cap
// must drop the start to plain 720p rather than honoring only one axis.
renderHook(
() =>
useTranscodeQuality({
sessionId: "sess-1",
selectedVersion: version,
versions: [version],
playMethod: "remux",
initialPosition: 0,
qualityPreference: "720p",
maxBitrateKbps: 2000,
}),
{ wrapper },
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const body = sentBodies()[0]!;
expect(body.target_resolution).toBe("720p");
expect(body.target_bitrate_kbps).toBe(2000);
});
});
+48 -5
View File
@@ -10,6 +10,7 @@ import type {
TranscodeStartRequest,
} from "../types";
import { QUALITY_TO_RESOLUTION } from "./useCodecDetection";
import { formatBitrateKbps } from "@/lib/bitrateOptions";
/** Quality tier definition. ID is frontend-only; backend receives resolution + bitrate separately. */
interface QualityTierDef {
@@ -57,6 +58,14 @@ interface UseTranscodeQualityParams {
playMethod: PlayMethod | null;
initialPosition: number;
qualityPreference?: string | null;
/**
* Bandwidth cap in kbps (playback.max_bitrate_kbps); null/undefined is
* uncapped. Applied when picking the startup tier for HLS sessions. A
* direct-play base is not restarted to enforce it — the server chooses the
* play method without seeing this cap, and an explicit in-player pick
* always wins over it.
*/
maxBitrateKbps?: number | null;
transportRestart?: PlaybackTransportRestart | null;
}
@@ -102,11 +111,7 @@ export const COMPATIBILITY_QUALITY_ID = "compatibility";
// switcher; the two pick from the same ladder and should not disagree about how
// to spell a number.
export function formatQualityBitrate(kbps: number): string {
if (kbps >= 1000) {
const mbps = kbps / 1000;
return mbps % 1 === 0 ? `${mbps} Mbps` : `${mbps.toFixed(1)} Mbps`;
}
return `${kbps} kbps`;
return formatBitrateKbps(kbps);
}
function fallbackBitrateForResolution(resolution: string, sourceBitrate: number): number {
@@ -120,6 +125,34 @@ function fallbackBitrateForResolution(resolution: string, sourceBitrate: number)
return 6000;
}
/**
* The startup tier after the bandwidth cap: the given id when it fits (or no
* cap is set), otherwise the highest tier at or under the cap.
*
* "Original" carries the file's own bitrate; tiers carry their preset. An
* unknown file bitrate (0) is left alone rather than transcoded on a guess.
* Options arrive in descending quality order, so the first tier under the cap
* is also the best one; when even the smallest tier exceeds the cap, that
* smallest tier is the closest the ladder can get.
*/
function capStartupQuality(
qualityId: string,
options: QualityOption[],
version: PlayerFileVersion | undefined,
maxBitrateKbps: number | null | undefined,
): string {
if (maxBitrateKbps == null) return qualityId;
const chosen = options.find((o) => o.id === qualityId);
const chosenBitrate = chosen?.isOriginal ? (version?.bitrate ?? 0) : (chosen?.bitrateKbps ?? 0);
if (chosenBitrate <= 0 || chosenBitrate <= maxBitrateKbps) return qualityId;
const tiers = options.filter((o) => !o.isOriginal && o.bitrateKbps > 0);
const withinCap = tiers.find((o) => o.bitrateKbps <= maxBitrateKbps);
if (withinCap) return withinCap.id;
const smallest = tiers[tiers.length - 1];
return smallest ? smallest.id : qualityId;
}
function playMethodLabel(method: PlayMethod | null): string {
switch (method) {
case "direct":
@@ -194,6 +227,7 @@ export function useTranscodeQuality({
playMethod,
initialPosition,
qualityPreference,
maxBitrateKbps,
transportRestart,
}: UseTranscodeQualityParams): UseTranscodeQualityResult {
const config = usePlayerConfig();
@@ -601,9 +635,18 @@ export function useTranscodeQuality({
}
}
autoStartQuality = capStartupQuality(
autoStartQuality,
qualityOptions,
effectiveVersion,
maxBitrateKbps,
);
startTranscode(autoStartQuality, initialPosition, true);
}, [
effectiveVersion,
initialPosition,
maxBitrateKbps,
playMethod,
qualityOptions,
qualityPreference,
+2
View File
@@ -290,6 +290,8 @@ export interface WatchPageProps {
initialPosition?: number;
forceInitialPosition?: boolean;
qualityPreference?: string | null;
/** Bandwidth cap in kbps from playback.max_bitrate_kbps; null/undefined is uncapped. */
maxBitrateKbps?: number | null;
explicitAudioTrackIndex?: number | null;
preferredSubtitleLanguage?: string | null;
preferredSubtitleTrackSignature?: PlayerSubtitleTrackSignature | null;