diff --git a/apps/web/src/components/player-defaults.tsx b/apps/web/src/components/player-defaults.tsx index 6a6b4ee5..0e534525 100644 --- a/apps/web/src/components/player-defaults.tsx +++ b/apps/web/src/components/player-defaults.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from "react"; import { normalizeDefaultPlaybackSpeed } from "../lib/playback-speed"; +import { qualityLabelHeight, qualityOptionHeight } from "../lib/player-quality"; import type { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference"; import { useAudioOptions, @@ -30,13 +31,6 @@ type PlaybackSpeedDefaultProps = { preference?: SabrPlaybackRatePreference; }; -function qualityLabelHeight(label: string): number | null { - const match = label.match(/(\d+)/); - if (!match) return null; - const height = Number(match[1]); - return Number.isFinite(height) ? height : null; -} - export function PlayerDefaults({ defaultQuality, defaultPlaybackSpeed, @@ -74,7 +68,7 @@ export function PlayerDefaults({ const defaultHeight = qualityLabelHeight(defaultQuality); const exactMatch = qualityOptions.find((o) => o.label === defaultQuality); const heightMatch = qualityOptions.find( - (o) => defaultHeight !== null && o.quality?.height === defaultHeight, + (o) => defaultHeight !== null && qualityOptionHeight(o) === defaultHeight, ); const match = exactMatch ?? heightMatch; if (!match) return; diff --git a/apps/web/src/components/quality-selector.tsx b/apps/web/src/components/quality-selector.tsx index 4610d618..c9940ea7 100644 --- a/apps/web/src/components/quality-selector.tsx +++ b/apps/web/src/components/quality-selector.tsx @@ -3,6 +3,7 @@ import { useRef } from "react"; import { useInterfaceLocale } from "../hooks/use-interface-locale"; import { useDashPlayerSnapshot } from "../lib/dash-player-store"; import { dashQualityOptions, selectDashTrack, selectedDashHeight } from "../lib/dash-video"; +import { qualityOptionHeight } from "../lib/player-quality"; import { sabrResolutionOptions } from "../lib/sabr-quality-selection"; import type { DefaultLayoutIcon, MenuInstance } from "../lib/vidstack"; import { @@ -23,7 +24,8 @@ const QUALITY_OPTIONS = { sort: "descending" } as const; type QualityOption = ReturnType[number]; function qualityValue(option: QualityOption): string { - return String(option.quality?.height ?? option.label); + const height = qualityOptionHeight(option); + return height !== null ? String(height) : option.label; } function collectResolutionOptions(options: QualityOption[]): QualityOption[] { @@ -131,7 +133,7 @@ export function QualitySelector() { const radioOptions = filteredOptions.map((o) => ({ label: o.label, value: qualityValue(o) })); if (filteredOptions.length <= 1) return null; - if (filteredOptions.every((o) => (o.quality?.height ?? 0) === 0)) return null; + if (filteredOptions.every((o) => (qualityOptionHeight(o) ?? 0) === 0)) return null; if (!selected) return null; const current = selected.label; diff --git a/apps/web/src/lib/player-quality.ts b/apps/web/src/lib/player-quality.ts new file mode 100644 index 00000000..d1bdd62a --- /dev/null +++ b/apps/web/src/lib/player-quality.ts @@ -0,0 +1,18 @@ +type QualityOptionLike = { + label: string; + height?: number; + quality?: { height?: number | null } | null; +}; + +export function qualityLabelHeight(label: string): number | null { + const match = label.match(/(\d+)/); + if (!match) return null; + const height = Number(match[1]); + return Number.isFinite(height) && height > 0 ? height : null; +} + +export function qualityOptionHeight(option: QualityOptionLike): number | null { + if (option.quality?.height && option.quality.height > 0) return option.quality.height; + if (option.height && option.height > 0) return option.height; + return qualityLabelHeight(option.label); +} diff --git a/apps/web/tests/player-quality.test.ts b/apps/web/tests/player-quality.test.ts new file mode 100644 index 00000000..810a191f --- /dev/null +++ b/apps/web/tests/player-quality.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { qualityLabelHeight, qualityOptionHeight } from "../src/lib/player-quality"; + +test("prefers the quality height and falls back to a positive option height", () => { + expect(qualityOptionHeight({ label: "720p", quality: { height: 1080 } })).toBe(1080); + expect(qualityOptionHeight({ label: "720p", height: 1080 })).toBe(1080); +}); + +test("falls back to the label when heights are missing or zero", () => { + expect(qualityOptionHeight({ label: "720p", quality: { height: 0 }, height: 0 })).toBe(720); + expect(qualityOptionHeight({ label: "1080p60" })).toBe(1080); +}); + +test("rejects labels without a positive resolution", () => { + expect(qualityLabelHeight("Auto")).toBeNull(); + expect(qualityLabelHeight("0p")).toBeNull(); +});