refactor(player): share the volume core across both players + extract PlexPlayerSettings (R12 S5 frontend)
Volume-consolidation (conservative — no behaviour or prefs-format change): - playerVolume.ts is now scale-generic (a VolumeScale parameterises range / default / integer rounding). YT_SCALE (0-100 ints) and PLEX_SCALE (0..1 float) bind the same tested core; the YouTube wrappers keep the original API so the existing tests are unchanged. - The Plex usePlayerVolume hook drops its bespoke clamp01 arithmetic for the shared normalizeVolumeIn / stepVolumeIn at PLEX_SCALE. Side effect (a latent bug fix): a corrupt persisted volume now falls back to the default instead of a bare clamp silently muting it. The separate .muted flag stays Plex-only. - clamp01 stays for the seek-bar fraction (its non-volume use). - Tests: the generic core is covered at both scales (float no-round, corrupt -> default, step/clamp) — the Plex-side arithmetic finally has unit coverage. PlexPlayerSettings extraction (verbatim JSX move, behaviour-equivalent): - The ~240-line gear/tuning panel (sync / playback / subtitle / clock tabs) moves out of PlexPlayer into PlexPlayerSettings.tsx (a pure prefs+patchPrefs view owning no state). PlexPlayer shrinks 1402 -> 1173 lines; Slider/Toggle imports it no longer uses are dropped. Gate green (tsc / eslint-0 / prettier / knip / vitest 83 / pytest 234). Dev server smoke: app builds + loads clean, no console errors. The interactive settings panel needs a playing Plex video (unreachable on localdev) -> owed to the user's UAT.
This commit is contained in:
@@ -36,7 +36,8 @@ import {
|
|||||||
subShadowCss,
|
subShadowCss,
|
||||||
type PlexPlayerPrefs,
|
type PlexPlayerPrefs,
|
||||||
} from "../lib/plexPlayerPrefs";
|
} from "../lib/plexPlayerPrefs";
|
||||||
import { Ctrl, PanelHead, Slider, Toggle } from "./plexPlayerControls";
|
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
||||||
|
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||||
import {
|
import {
|
||||||
useAutoSkip,
|
useAutoSkip,
|
||||||
useControlsVisibility,
|
useControlsVisibility,
|
||||||
@@ -1048,246 +1049,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
<Settings className="w-5 h-5" />
|
<Settings className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div
|
<PlexPlayerSettings
|
||||||
ref={menuRef}
|
menuRef={menuRef}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
settingsTabs={settingsTabs}
|
||||||
onClick={(e) => e.stopPropagation()}
|
activeTab={activeTab}
|
||||||
className="border border-white/15 bg-neutral-900/95 absolute bottom-full right-0 mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
|
setSettingsTab={setSettingsTab}
|
||||||
>
|
prefs={prefs}
|
||||||
<div className="mb-1.5 flex gap-1">
|
patchPrefs={patchPrefs}
|
||||||
{settingsTabs.map((tab) => (
|
changeAudioOffset={changeAudioOffset}
|
||||||
<button
|
now={now}
|
||||||
key={tab.key}
|
/>
|
||||||
onClick={() => setSettingsTab(tab.key)}
|
|
||||||
className={`flex-1 whitespace-nowrap rounded px-2 py-1 text-2xs ${
|
|
||||||
activeTab === tab.key
|
|
||||||
? "bg-white/15 text-accent"
|
|
||||||
: "text-white/70 hover:bg-white/10"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === "sync" && (
|
|
||||||
<>
|
|
||||||
<Slider
|
|
||||||
label={t("plex.player.subOffset")}
|
|
||||||
value={prefs.subOffset}
|
|
||||||
min={-10}
|
|
||||||
max={10}
|
|
||||||
step={0.1}
|
|
||||||
suffix="s"
|
|
||||||
onChange={(v) => patchPrefs({ subOffset: v })}
|
|
||||||
onReset={() => patchPrefs({ subOffset: 0 })}
|
|
||||||
/>
|
|
||||||
<Slider
|
|
||||||
label={t("plex.player.audioOffset")}
|
|
||||||
value={prefs.audioOffset}
|
|
||||||
min={-5}
|
|
||||||
max={5}
|
|
||||||
step={0.1}
|
|
||||||
suffix="s"
|
|
||||||
onChange={changeAudioOffset}
|
|
||||||
onReset={() => changeAudioOffset(0)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "playback" && (
|
|
||||||
<>
|
|
||||||
<Slider
|
|
||||||
label={t("plex.player.seekStep")}
|
|
||||||
value={prefs.seekStep}
|
|
||||||
min={5}
|
|
||||||
max={30}
|
|
||||||
step={1}
|
|
||||||
suffix="s"
|
|
||||||
onChange={(v) => patchPrefs({ seekStep: v })}
|
|
||||||
/>
|
|
||||||
<Slider
|
|
||||||
label={t("plex.player.fineSeek")}
|
|
||||||
value={prefs.fineSeekStep}
|
|
||||||
min={0.1}
|
|
||||||
max={5}
|
|
||||||
step={0.1}
|
|
||||||
suffix="s"
|
|
||||||
onChange={(v) => patchPrefs({ fineSeekStep: v })}
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label={t("plex.player.autoHide")}
|
|
||||||
checked={prefs.barAutoHide}
|
|
||||||
onChange={(v) => patchPrefs({ barAutoHide: v })}
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label={t("plex.player.autoSkipIntro")}
|
|
||||||
checked={prefs.autoSkipIntro}
|
|
||||||
onChange={(v) => patchPrefs({ autoSkipIntro: v })}
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label={t("plex.player.autoSkipCredits")}
|
|
||||||
checked={prefs.autoSkipCredits}
|
|
||||||
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
|
|
||||||
/>
|
|
||||||
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
|
|
||||||
<Slider
|
|
||||||
label={t("plex.player.autoSkipDelay")}
|
|
||||||
value={prefs.autoSkipDelay}
|
|
||||||
min={0}
|
|
||||||
max={10}
|
|
||||||
step={1}
|
|
||||||
suffix="s"
|
|
||||||
onChange={(v) => patchPrefs({ autoSkipDelay: v })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "subtitle" && (
|
|
||||||
<>
|
|
||||||
<Slider
|
|
||||||
label={t("plex.player.subSize")}
|
|
||||||
value={prefs.subSize}
|
|
||||||
min={50}
|
|
||||||
max={250}
|
|
||||||
step={5}
|
|
||||||
suffix="%"
|
|
||||||
onChange={(v) => patchPrefs({ subSize: v })}
|
|
||||||
/>
|
|
||||||
<Slider
|
|
||||||
label={t("plex.player.subPos")}
|
|
||||||
value={prefs.subPos}
|
|
||||||
min={50}
|
|
||||||
max={95}
|
|
||||||
step={1}
|
|
||||||
suffix="%"
|
|
||||||
onChange={(v) => patchPrefs({ subPos: v })}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between px-1 py-1.5">
|
|
||||||
<span className="text-[0.75rem] text-white/80">
|
|
||||||
{t("plex.player.subColor")}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={prefs.subColor}
|
|
||||||
onChange={(e) => patchPrefs({ subColor: e.target.value })}
|
|
||||||
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between px-1 py-1.5">
|
|
||||||
<span className="text-[0.75rem] text-white/80">
|
|
||||||
{t("plex.player.subBg")}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="flex items-center gap-1 text-2xs text-white/70">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={prefs.subBg === "transparent"}
|
|
||||||
onChange={(e) =>
|
|
||||||
patchPrefs({
|
|
||||||
subBg: e.target.checked ? "transparent" : "#000000",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="accent-accent"
|
|
||||||
/>
|
|
||||||
{t("plex.player.subBgNone")}
|
|
||||||
</label>
|
|
||||||
{prefs.subBg !== "transparent" && (
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={prefs.subBg}
|
|
||||||
onChange={(e) => patchPrefs({ subBg: e.target.value })}
|
|
||||||
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
label={t("plex.player.subShadow")}
|
|
||||||
value={prefs.subShadow}
|
|
||||||
min={0}
|
|
||||||
max={10}
|
|
||||||
step={1}
|
|
||||||
suffix="px"
|
|
||||||
onChange={(v) => patchPrefs({ subShadow: v })}
|
|
||||||
/>
|
|
||||||
{prefs.subShadow > 0 && (
|
|
||||||
<div className="flex items-center justify-between px-1 py-1.5">
|
|
||||||
<span className="text-[0.75rem] text-white/80">
|
|
||||||
{t("plex.player.subShadowColor")}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={prefs.subShadowColor}
|
|
||||||
onChange={(e) => patchPrefs({ subShadowColor: e.target.value })}
|
|
||||||
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "clock" && (
|
|
||||||
<>
|
|
||||||
<Toggle
|
|
||||||
label={t("plex.player.clockShow")}
|
|
||||||
checked={prefs.clockShow}
|
|
||||||
onChange={(v) => patchPrefs({ clockShow: v })}
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label={t("plex.player.clock24h")}
|
|
||||||
checked={prefs.clock24h}
|
|
||||||
onChange={(v) => patchPrefs({ clock24h: v })}
|
|
||||||
/>
|
|
||||||
<Slider
|
|
||||||
label={t("plex.player.clockSize")}
|
|
||||||
value={prefs.clockSize}
|
|
||||||
min={70}
|
|
||||||
max={200}
|
|
||||||
step={5}
|
|
||||||
suffix="%"
|
|
||||||
onChange={(v) => patchPrefs({ clockSize: v })}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between px-1 py-1.5">
|
|
||||||
<span className="text-[0.75rem] text-white/80">
|
|
||||||
{t("plex.player.clockColor")}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={prefs.clockColor}
|
|
||||||
onChange={(e) => patchPrefs({ clockColor: e.target.value })}
|
|
||||||
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Toggle
|
|
||||||
label={t("plex.player.clockDate")}
|
|
||||||
checked={prefs.clockDate}
|
|
||||||
onChange={(v) => patchPrefs({ clockDate: v })}
|
|
||||||
/>
|
|
||||||
{prefs.clockDate && (
|
|
||||||
<div className="flex gap-1 px-1 py-1">
|
|
||||||
{(["hu", "en"] as const).map((st) => (
|
|
||||||
<button
|
|
||||||
key={st}
|
|
||||||
onClick={() => patchPrefs({ clockDateStyle: st })}
|
|
||||||
className={`flex-1 rounded px-2 py-1 text-2xs ${
|
|
||||||
prefs.clockDateStyle === st
|
|
||||||
? "bg-white/15 text-accent"
|
|
||||||
: "text-white/70 hover:bg-white/10"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st })
|
|
||||||
.date
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { type RefObject } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Slider, Toggle } from "./plexPlayerControls";
|
||||||
|
import { clockParts, type PlexPlayerPrefs } from "../lib/plexPlayerPrefs";
|
||||||
|
|
||||||
|
type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
|
||||||
|
|
||||||
|
// The gear/tuning panel of the Plex player — a tabbed (sync / playback / subtitle / clock) sheet of
|
||||||
|
// Slider/Toggle rows over the per-account prefs. Extracted verbatim from PlexPlayer to keep that
|
||||||
|
// component focused on the media engine; every row is still `prefs.<field>` + `patchPrefs`, so the
|
||||||
|
// panel owns no state of its own. `now` is the live wall clock (passed in, not a second ticker) used
|
||||||
|
// only for the clock-date-style preview buttons.
|
||||||
|
export function PlexPlayerSettings(props: {
|
||||||
|
menuRef: RefObject<HTMLDivElement>;
|
||||||
|
settingsTabs: { key: SettingsTab; label: string }[];
|
||||||
|
activeTab: SettingsTab;
|
||||||
|
setSettingsTab: (tab: SettingsTab) => void;
|
||||||
|
prefs: PlexPlayerPrefs;
|
||||||
|
patchPrefs: (patch: Partial<PlexPlayerPrefs>) => void;
|
||||||
|
changeAudioOffset: (v: number) => void;
|
||||||
|
now: Date;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
menuRef,
|
||||||
|
settingsTabs,
|
||||||
|
activeTab,
|
||||||
|
setSettingsTab,
|
||||||
|
prefs,
|
||||||
|
patchPrefs,
|
||||||
|
changeAudioOffset,
|
||||||
|
now,
|
||||||
|
} = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="border border-white/15 bg-neutral-900/95 absolute bottom-full right-0 mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="mb-1.5 flex gap-1">
|
||||||
|
{settingsTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setSettingsTab(tab.key)}
|
||||||
|
className={`flex-1 whitespace-nowrap rounded px-2 py-1 text-2xs ${
|
||||||
|
activeTab === tab.key ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "sync" && (
|
||||||
|
<>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.subOffset")}
|
||||||
|
value={prefs.subOffset}
|
||||||
|
min={-10}
|
||||||
|
max={10}
|
||||||
|
step={0.1}
|
||||||
|
suffix="s"
|
||||||
|
onChange={(v) => patchPrefs({ subOffset: v })}
|
||||||
|
onReset={() => patchPrefs({ subOffset: 0 })}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.audioOffset")}
|
||||||
|
value={prefs.audioOffset}
|
||||||
|
min={-5}
|
||||||
|
max={5}
|
||||||
|
step={0.1}
|
||||||
|
suffix="s"
|
||||||
|
onChange={changeAudioOffset}
|
||||||
|
onReset={() => changeAudioOffset(0)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "playback" && (
|
||||||
|
<>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.seekStep")}
|
||||||
|
value={prefs.seekStep}
|
||||||
|
min={5}
|
||||||
|
max={30}
|
||||||
|
step={1}
|
||||||
|
suffix="s"
|
||||||
|
onChange={(v) => patchPrefs({ seekStep: v })}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.fineSeek")}
|
||||||
|
value={prefs.fineSeekStep}
|
||||||
|
min={0.1}
|
||||||
|
max={5}
|
||||||
|
step={0.1}
|
||||||
|
suffix="s"
|
||||||
|
onChange={(v) => patchPrefs({ fineSeekStep: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={t("plex.player.autoHide")}
|
||||||
|
checked={prefs.barAutoHide}
|
||||||
|
onChange={(v) => patchPrefs({ barAutoHide: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={t("plex.player.autoSkipIntro")}
|
||||||
|
checked={prefs.autoSkipIntro}
|
||||||
|
onChange={(v) => patchPrefs({ autoSkipIntro: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={t("plex.player.autoSkipCredits")}
|
||||||
|
checked={prefs.autoSkipCredits}
|
||||||
|
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
|
||||||
|
/>
|
||||||
|
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.autoSkipDelay")}
|
||||||
|
value={prefs.autoSkipDelay}
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
suffix="s"
|
||||||
|
onChange={(v) => patchPrefs({ autoSkipDelay: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "subtitle" && (
|
||||||
|
<>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.subSize")}
|
||||||
|
value={prefs.subSize}
|
||||||
|
min={50}
|
||||||
|
max={250}
|
||||||
|
step={5}
|
||||||
|
suffix="%"
|
||||||
|
onChange={(v) => patchPrefs({ subSize: v })}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.subPos")}
|
||||||
|
value={prefs.subPos}
|
||||||
|
min={50}
|
||||||
|
max={95}
|
||||||
|
step={1}
|
||||||
|
suffix="%"
|
||||||
|
onChange={(v) => patchPrefs({ subPos: v })}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between px-1 py-1.5">
|
||||||
|
<span className="text-[0.75rem] text-white/80">{t("plex.player.subColor")}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={prefs.subColor}
|
||||||
|
onChange={(e) => patchPrefs({ subColor: e.target.value })}
|
||||||
|
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-1 py-1.5">
|
||||||
|
<span className="text-[0.75rem] text-white/80">{t("plex.player.subBg")}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex items-center gap-1 text-2xs text-white/70">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={prefs.subBg === "transparent"}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchPrefs({
|
||||||
|
subBg: e.target.checked ? "transparent" : "#000000",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="accent-accent"
|
||||||
|
/>
|
||||||
|
{t("plex.player.subBgNone")}
|
||||||
|
</label>
|
||||||
|
{prefs.subBg !== "transparent" && (
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={prefs.subBg}
|
||||||
|
onChange={(e) => patchPrefs({ subBg: e.target.value })}
|
||||||
|
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.subShadow")}
|
||||||
|
value={prefs.subShadow}
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
suffix="px"
|
||||||
|
onChange={(v) => patchPrefs({ subShadow: v })}
|
||||||
|
/>
|
||||||
|
{prefs.subShadow > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-1 py-1.5">
|
||||||
|
<span className="text-[0.75rem] text-white/80">
|
||||||
|
{t("plex.player.subShadowColor")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={prefs.subShadowColor}
|
||||||
|
onChange={(e) => patchPrefs({ subShadowColor: e.target.value })}
|
||||||
|
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "clock" && (
|
||||||
|
<>
|
||||||
|
<Toggle
|
||||||
|
label={t("plex.player.clockShow")}
|
||||||
|
checked={prefs.clockShow}
|
||||||
|
onChange={(v) => patchPrefs({ clockShow: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={t("plex.player.clock24h")}
|
||||||
|
checked={prefs.clock24h}
|
||||||
|
onChange={(v) => patchPrefs({ clock24h: v })}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.clockSize")}
|
||||||
|
value={prefs.clockSize}
|
||||||
|
min={70}
|
||||||
|
max={200}
|
||||||
|
step={5}
|
||||||
|
suffix="%"
|
||||||
|
onChange={(v) => patchPrefs({ clockSize: v })}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between px-1 py-1.5">
|
||||||
|
<span className="text-[0.75rem] text-white/80">{t("plex.player.clockColor")}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={prefs.clockColor}
|
||||||
|
onChange={(e) => patchPrefs({ clockColor: e.target.value })}
|
||||||
|
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
label={t("plex.player.clockDate")}
|
||||||
|
checked={prefs.clockDate}
|
||||||
|
onChange={(v) => patchPrefs({ clockDate: v })}
|
||||||
|
/>
|
||||||
|
{prefs.clockDate && (
|
||||||
|
<div className="flex gap-1 px-1 py-1">
|
||||||
|
{(["hu", "en"] as const).map((st) => (
|
||||||
|
<button
|
||||||
|
key={st}
|
||||||
|
onClick={() => patchPrefs({ clockDateStyle: st })}
|
||||||
|
className={`flex-1 rounded px-2 py-1 text-2xs ${
|
||||||
|
prefs.clockDateStyle === st
|
||||||
|
? "bg-white/15 text-accent"
|
||||||
|
: "text-white/70 hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "./playerVolume";
|
import {
|
||||||
|
DEFAULT_VOLUME,
|
||||||
|
PLEX_SCALE,
|
||||||
|
YT_SCALE,
|
||||||
|
normalizeVolume,
|
||||||
|
normalizeVolumeIn,
|
||||||
|
stepVolume,
|
||||||
|
stepVolumeIn,
|
||||||
|
} from "./playerVolume";
|
||||||
|
|
||||||
describe("normalizeVolume", () => {
|
describe("normalizeVolume", () => {
|
||||||
it("keeps a valid level", () => {
|
it("keeps a valid level", () => {
|
||||||
@@ -56,3 +64,34 @@ describe("stepVolume", () => {
|
|||||||
expect(stepVolume(NaN, { delta: -5, fallback: 90 })).toBe(85);
|
expect(stepVolume(NaN, { delta: -5, fallback: 90 })).toBe(85);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("scale-generic core (shared by both players)", () => {
|
||||||
|
it("keeps 0..1 floats WITHOUT rounding on the Plex scale", () => {
|
||||||
|
// PLEX_SCALE.round is false — the HTML5 <video> takes a float, so 0.37 must stay 0.37 (the YT
|
||||||
|
// scale would round it to a whole percent).
|
||||||
|
expect(normalizeVolumeIn(0.37, undefined, PLEX_SCALE)).toBeCloseTo(0.37, 10);
|
||||||
|
expect(normalizeVolumeIn(1.5, undefined, PLEX_SCALE)).toBe(1); // clamps to max
|
||||||
|
expect(normalizeVolumeIn(-0.2, undefined, PLEX_SCALE)).toBe(0); // clamps to min
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the scale default on a corrupt Plex volume instead of muting", () => {
|
||||||
|
// The Plex-side latent bug this consolidation fixes: a bare clamp turned a corrupt persisted
|
||||||
|
// volume (null / "0.5" / NaN) into 0 — silently muting — where the shared core falls back to the
|
||||||
|
// last-known level, else PLEX_SCALE.default (1).
|
||||||
|
expect(normalizeVolumeIn(null, undefined, PLEX_SCALE)).toBe(PLEX_SCALE.default);
|
||||||
|
expect(normalizeVolumeIn("0.5", 0.3, PLEX_SCALE)).toBeCloseTo(0.3, 10);
|
||||||
|
expect(normalizeVolumeIn(NaN, NaN, PLEX_SCALE)).toBe(PLEX_SCALE.default);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("steps and clamps on the Plex scale", () => {
|
||||||
|
expect(stepVolumeIn(0.5, { delta: 0.05, fallback: 0.5 }, PLEX_SCALE)).toBeCloseTo(0.55, 10);
|
||||||
|
expect(stepVolumeIn(0.98, { delta: 0.05, fallback: 1 }, PLEX_SCALE)).toBe(1); // clamps at max
|
||||||
|
expect(stepVolumeIn(0.03, { delta: -0.05, fallback: 1 }, PLEX_SCALE)).toBe(0); // clamps at min
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still rounds to whole percents on the YouTube scale via the generic core", () => {
|
||||||
|
expect(normalizeVolumeIn(42.6, undefined, YT_SCALE)).toBe(43);
|
||||||
|
expect(YT_SCALE.round).toBe(true);
|
||||||
|
expect(PLEX_SCALE.round).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,33 +1,68 @@
|
|||||||
// The YouTube player's volume arithmetic, kept out of PlayerModal so it can be unit-tested. Every
|
// Volume arithmetic for BOTH players, kept out of the components so it can be unit-tested. Every
|
||||||
// rule here was established by a bug: a wheel spin that lost notches to the iframe's lagging
|
// rule here was established by a bug: a wheel spin that lost notches to the iframe's lagging
|
||||||
// getVolume, a partial stored object that became NaN and persisted as `null`, and a "corrupt →
|
// getVolume, a partial stored object that became NaN and persisted as `null`, and a "corrupt →
|
||||||
// default" fallback that jumped an already-quiet session to full blast.
|
// default" fallback that jumped an already-quiet session to full blast.
|
||||||
|
//
|
||||||
|
// The two players run on different native scales — the YouTube iframe API takes 0–100 integers, the
|
||||||
|
// HTML5 <video> element a 0..1 float with a separate `.muted` flag — so the pure core below is
|
||||||
|
// scale-agnostic (a `VolumeScale` parameterises the range, default, and integer rounding) and each
|
||||||
|
// player binds it to its own scale. The YouTube-bound wrappers keep the original 0–100 API.
|
||||||
|
|
||||||
/** Level used when nothing trustworthy is available. */
|
/** A player's volume scale. `round` is true for the integer YouTube API, false for the 0..1 float. */
|
||||||
export const DEFAULT_VOLUME = 100;
|
export interface VolumeScale {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
/** Level used when nothing trustworthy is available. */
|
||||||
|
default: number;
|
||||||
|
round: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const clamp = (n: number) => Math.max(0, Math.min(100, Math.round(n)));
|
/** YouTube iframe API: 0–100 integers, no mute flag (level 0 IS muted). */
|
||||||
|
export const YT_SCALE: VolumeScale = { min: 0, max: 100, default: 100, round: true };
|
||||||
|
/** HTML5 <video>: a 0..1 float (mute is a separate flag the caller carries). */
|
||||||
|
export const PLEX_SCALE: VolumeScale = { min: 0, max: 1, default: 1, round: false };
|
||||||
|
|
||||||
|
function clampTo(n: number, s: VolumeScale): number {
|
||||||
|
const c = Math.max(s.min, Math.min(s.max, n));
|
||||||
|
return s.round ? Math.round(c) : c;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coerce anything that claims to be a volume into a usable 0–100 level.
|
* Coerce anything that claims to be a volume into a usable level ON `scale`.
|
||||||
*
|
*
|
||||||
* Only a real, finite `number` is accepted: the stored blob is JSON, so a level is a number or it
|
* Only a real, finite `number` is accepted: the stored blob is JSON, so a level is a number or it
|
||||||
* is corrupt — `null`, `undefined`, `"80"` and `NaN` all mean "we don't know", and `Number(null)`
|
* is corrupt — `null`, `undefined`, `"80"` and `NaN` all mean "we don't know", and `Number(null)`
|
||||||
* being `0` (silently muting the player) is exactly the coercion to avoid. `fallback` is the last
|
* being `0` (silently muting the player) is exactly the coercion to avoid. `fallback` is the last
|
||||||
* level known to work; it is itself validated, so a corrupt value can never travel through it.
|
* level known to work; it is itself validated, so a corrupt value can never travel through it.
|
||||||
*/
|
*/
|
||||||
export function normalizeVolume(raw: unknown, fallback: unknown = DEFAULT_VOLUME): number {
|
export function normalizeVolumeIn(raw: unknown, fallback: unknown, scale: VolumeScale): number {
|
||||||
if (typeof raw === "number" && Number.isFinite(raw)) return clamp(raw);
|
if (typeof raw === "number" && Number.isFinite(raw)) return clampTo(raw, scale);
|
||||||
if (typeof fallback === "number" && Number.isFinite(fallback)) return clamp(fallback);
|
if (typeof fallback === "number" && Number.isFinite(fallback)) return clampTo(fallback, scale);
|
||||||
return DEFAULT_VOLUME;
|
return scale.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One volume step (wheel notch / arrow key) from `current`.
|
/** One volume step (wheel notch / arrow key) from `current`, on `scale`.
|
||||||
*
|
*
|
||||||
* `delta` and `fallback` ride in an options object on purpose: as two adjacent `number`
|
* `delta` and `fallback` ride in an options object on purpose: as two adjacent `number`
|
||||||
* parameters they were transposable without a type error, and swapping them silently steps the
|
* parameters they were transposable without a type error, and swapping them silently steps the
|
||||||
* volume BY the current level. */
|
* volume BY the current level. */
|
||||||
|
export function stepVolumeIn(
|
||||||
|
current: unknown,
|
||||||
|
opts: { delta: number; fallback: unknown },
|
||||||
|
scale: VolumeScale,
|
||||||
|
): number {
|
||||||
|
return clampTo(normalizeVolumeIn(current, opts.fallback, scale) + opts.delta, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- YouTube-bound convenience wrappers (0–100 integers) — the original API, behaviour unchanged. ---
|
||||||
|
|
||||||
|
/** Level used when nothing trustworthy is available (YouTube scale). */
|
||||||
|
export const DEFAULT_VOLUME = YT_SCALE.default;
|
||||||
|
|
||||||
|
export function normalizeVolume(raw: unknown, fallback: unknown = DEFAULT_VOLUME): number {
|
||||||
|
return normalizeVolumeIn(raw, fallback, YT_SCALE);
|
||||||
|
}
|
||||||
|
|
||||||
export function stepVolume(current: unknown, opts: { delta: number; fallback: unknown }): number {
|
export function stepVolume(current: unknown, opts: { delta: number; fallback: unknown }): number {
|
||||||
const base = normalizeVolume(current, opts.fallback);
|
return stepVolumeIn(current, opts, YT_SCALE);
|
||||||
return clamp(base + opts.delta);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import {
|
|||||||
} from "./fullscreen";
|
} from "./fullscreen";
|
||||||
import { api, type PlexItemDetail, type PlexMarker } from "./api";
|
import { api, type PlexItemDetail, type PlexMarker } from "./api";
|
||||||
import { qk } from "./queryKeys";
|
import { qk } from "./queryKeys";
|
||||||
import { clamp01, type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
||||||
|
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
|
||||||
|
|
||||||
// A live wall clock for the top bar, re-rendered every 15s (minute resolution is enough for the
|
// A live wall clock for the top bar, re-rendered every 15s (minute resolution is enough for the
|
||||||
// HH:MM display, and a coarse tick keeps the whole player from re-rendering each second).
|
// HH:MM display, and a coarse tick keeps the whole player from re-rendering each second).
|
||||||
@@ -138,9 +139,10 @@ export function usePlexProgress(opts: {
|
|||||||
|
|
||||||
// Volume/mute for the Plex player's HTML5 <video> (0..1 float, independent .muted flag). Persists to
|
// Volume/mute for the Plex player's HTML5 <video> (0..1 float, independent .muted flag). Persists to
|
||||||
// the per-account prefs (survives F5) and re-applies on each fresh session (the element resets to its
|
// the per-account prefs (survives F5) and re-applies on each fresh session (the element resets to its
|
||||||
// default volume after a load/seek-restart). NOTE: this is the Plex-side model; the YouTube player has
|
// default volume after a load/seek-restart). The 0..1 arithmetic is the shared, unit-tested core in
|
||||||
// its own 0-100 / no-mute-flag arithmetic in playerVolume.ts. Sharing the tested pure core across both
|
// playerVolume.ts bound to PLEX_SCALE (the YouTube player binds the same core to YT_SCALE, 0–100); the
|
||||||
// is a separate R12 consolidation sprint — this hook is the seam it will rework internally.
|
// separate .muted flag stays here since it's Plex-only. Using normalizeVolumeIn (not a bare clamp)
|
||||||
|
// also means a corrupt persisted volume falls back to the default instead of silently muting.
|
||||||
export function usePlayerVolume(opts: {
|
export function usePlayerVolume(opts: {
|
||||||
videoRef: RefObject<HTMLVideoElement | null>;
|
videoRef: RefObject<HTMLVideoElement | null>;
|
||||||
volume: number;
|
volume: number;
|
||||||
@@ -160,7 +162,7 @@ export function usePlayerVolume(opts: {
|
|||||||
(vol: number, mute: boolean) => {
|
(vol: number, mute: boolean) => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
v.volume = clamp01(vol);
|
v.volume = normalizeVolumeIn(vol, undefined, PLEX_SCALE);
|
||||||
v.muted = mute;
|
v.muted = mute;
|
||||||
},
|
},
|
||||||
[videoRef],
|
[videoRef],
|
||||||
@@ -169,12 +171,15 @@ export function usePlayerVolume(opts: {
|
|||||||
const setVol = useCallback(
|
const setVol = useCallback(
|
||||||
(vol: number, mute: boolean) => {
|
(vol: number, mute: boolean) => {
|
||||||
applyVolume(vol, mute);
|
applyVolume(vol, mute);
|
||||||
patchPrefs({ volume: clamp01(vol), muted: mute });
|
patchPrefs({ volume: normalizeVolumeIn(vol, undefined, PLEX_SCALE), muted: mute });
|
||||||
},
|
},
|
||||||
[applyVolume, patchPrefs],
|
[applyVolume, patchPrefs],
|
||||||
);
|
);
|
||||||
const nudgeVolume = useCallback(
|
const nudgeVolume = useCallback(
|
||||||
(d: number) => setVol(clamp01(volume + d), clamp01(volume + d) === 0),
|
(d: number) => {
|
||||||
|
const next = stepVolumeIn(volume, { delta: d, fallback: volume }, PLEX_SCALE);
|
||||||
|
setVol(next, next === 0);
|
||||||
|
},
|
||||||
[volume, setVol],
|
[volume, setVol],
|
||||||
);
|
);
|
||||||
const toggleMute = useCallback(() => setVol(volume, !muted), [volume, muted, setVol]);
|
const toggleMute = useCallback(() => setVol(volume, !muted), [volume, muted, setVol]);
|
||||||
|
|||||||
Reference in New Issue
Block a user