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. */