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 ( - ( -
- + save(SETTING_KEYS.PLAYBACK_PREFERRED_QUALITY, value).catch(() => + toast.error("Failed to save preferred quality"), + ) + } + > + - {selected === null && ( - - {describeQuality(resolution, bitrate)} - - )} - {QUALITY_PRESETS.map((preset) => ( - - {preset.label} + {optionsFor(qualityDefinition).map((option) => ( + + {option.label} ))} -

- {describeQuality(resolution, bitrate)} -

-
- )} - /> + )} + /> + + ( + + )} + /> + ); } 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;