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:
@@ -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. */
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user