diff --git a/web/src/components/settings/DeviceSettingGroups.tsx b/web/src/components/settings/DeviceSettingGroups.tsx
index cb1a410d..8c07cc9b 100644
--- a/web/src/components/settings/DeviceSettingGroups.tsx
+++ b/web/src/components/settings/DeviceSettingGroups.tsx
@@ -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. */
diff --git a/web/src/lib/bitrateOptions.ts b/web/src/lib/bitrateOptions.ts
new file mode 100644
index 00000000..90cb23c0
--- /dev/null
+++ b/web/src/lib/bitrateOptions.ts
@@ -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];
+}
diff --git a/web/src/lib/qualityPresets.test.ts b/web/src/lib/qualityPresets.test.ts
deleted file mode 100644
index 88de0230..00000000
--- a/web/src/lib/qualityPresets.test.ts
+++ /dev/null
@@ -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);
- }
- });
-});
diff --git a/web/src/lib/qualityPresets.ts b/web/src/lib/qualityPresets.ts
deleted file mode 100644
index 20338be8..00000000
--- a/web/src/lib/qualityPresets.ts
+++ /dev/null
@@ -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`;
-}
diff --git a/web/src/pages/SettingsLayout.tsx b/web/src/pages/SettingsLayout.tsx
index 561f430a..3b5cd8ec 100644
--- a/web/src/pages/SettingsLayout.tsx
+++ b/web/src/pages/SettingsLayout.tsx
@@ -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",
diff --git a/web/src/pages/settings/PlaybackSettings.test.tsx b/web/src/pages/settings/PlaybackSettings.test.tsx
index 37493f3b..543d12d2 100644
--- a/web/src/pages/settings/PlaybackSettings.test.tsx
+++ b/web/src/pages/settings/PlaybackSettings.test.tsx
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByRole("combobox", { name: "Maximum bitrate" }).textContent).toContain(
+ "12.3 Mbps",
+ );
+ });
});
diff --git a/web/src/pages/settings/PlaybackSettings.tsx b/web/src/pages/settings/PlaybackSettings.tsx
index 0cab0037..fc308649 100644
--- a/web/src/pages/settings/PlaybackSettings.tsx
+++ b/web/src/pages/settings/PlaybackSettings.tsx
@@ -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 (
- (
-
-
- )}
- />
+ )}
+ />
+
+ (
+ {
+ // "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"));
+ }}
+ >
+
+
+
+
+ {bitrateChoices.map((choice) => (
+
+ {choice.label}
+
+ ))}
+
+
+ )}
+ />
+ >
);
}
diff --git a/web/src/playback/WatchPlaybackChrome.tsx b/web/src/playback/WatchPlaybackChrome.tsx
index 8c7aa45f..c05eb56a 100644
--- a/web/src/playback/WatchPlaybackChrome.tsx
+++ b/web/src/playback/WatchPlaybackChrome.tsx
@@ -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) && }
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;
diff --git a/web/src/player/components/WatchPage.tsx b/web/src/player/components/WatchPage.tsx
index bf8a9766..5ee15a80 100644
--- a/web/src/player/components/WatchPage.tsx
+++ b/web/src/player/components/WatchPage.tsx
@@ -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}
diff --git a/web/src/player/hooks/useTranscodeQuality.test.tsx b/web/src/player/hooks/useTranscodeQuality.test.tsx
index 97d4c95c..fa66f365 100644
--- a/web/src/player/hooks/useTranscodeQuality.test.tsx
+++ b/web/src/player/hooks/useTranscodeQuality.test.tsx
@@ -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);
+ });
});
diff --git a/web/src/player/hooks/useTranscodeQuality.ts b/web/src/player/hooks/useTranscodeQuality.ts
index 6dcc062c..4743e179 100644
--- a/web/src/player/hooks/useTranscodeQuality.ts
+++ b/web/src/player/hooks/useTranscodeQuality.ts
@@ -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,
diff --git a/web/src/player/types.ts b/web/src/player/types.ts
index 65acebca..b682131f 100644
--- a/web/src/player/types.ts
+++ b/web/src/player/types.ts
@@ -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;