Files
controller-v2/client/src/pages/SelectPage.tsx
T

278 lines
9.3 KiB
TypeScript
Raw Normal View History

/* ============================================================
SELECT PAGE — Universal full-screen option picker
Design: iOS Settings style selection list
Usage: Navigate to /select with state object
State params:
title - Page title shown in header
options - Array of option strings
selected - Currently selected index (number)
back - Route to navigate back to after selection
key - Identifier for which setting this controls
============================================================ */
import { useLocation } from "wouter";
import { ChevronLeft, Check } from "lucide-react";
import { useDevice } from "@/contexts/DeviceContext";
import { cn } from "@/lib/utils";
2026-05-27 15:10:04 +08:00
import { useEffect, useMemo, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import localeZh from "@/locales/data-zh.json";
import localeEn from "@/locales/data-en.json";
import { resolveLocalePack } from "@/locales/resolveLocale";
import { usePageTitleWithConnection } from "@/hooks/usePageTitleWithConnection";
2026-05-27 15:10:04 +08:00
type DacVolumeDirectConfirmLocale = NonNullable<
NonNullable<(typeof localeZh)["dac"]>["dacVolumeDirectConfirm"]
>;
const iisModeImageUrl = (index: number) =>
`${import.meta.env.BASE_URL}images/IIS/iis_app${index + 1}.png`;
/* ── Key → API field mapping ── */
const KEY_TO_SETTING: Record<string, string> = {
effect_value: "effect_value",
crossfeed_value: "crossfeed_value",
language: "language",
analogGain: "analogGain",
soundStep: "soundStep",
bootSound: "bootSound",
filterCharacteristic: "pcm",
mutePolar: "hdmimutepolar",
IISMode: "hdmiType",
xlr: "xlr",
dacGain: "dacGain",
dacImpedance: "dacImpedance",
2026-05-27 15:10:04 +08:00
dacVolumeDirect: "dacVolumeDirect",
vu: "vu",
input: "input",
output: "output",
screenLight: "screenLight",
screenOff: "screenOff",
buttonLight: "buttonLight",
autoHome: "autoHome",
sleep: "sleep",
headphone: "peqSelect",
};
// Global state for select page (to avoid URL params)
export let selectPageState: {
title: string;
options: string[];
selected: number;
back: string;
key: string;
} | null = null;
// Last select result for pages that need local state update
export let selectPageResult: { key: string; selected: number } | null = null;
export function consumeSelectPageResult() {
const result = selectPageResult;
selectPageResult = null;
return result;
}
// Helper function to set select page state
export function navigateToSelect(title: string, options: string[], selected: number, back: string, key: string) {
selectPageState = { title, options, selected, back, key };
}
export default function SelectPage() {
const [location, setLocation] = useLocation();
2026-05-27 15:10:04 +08:00
const { updateSetting, deviceState } = useDevice();
const [state, setState] = useState<{
title: string;
options: string[];
selected: number;
back: string;
key: string;
} | null>(() => selectPageState);
const [saving, setSaving] = useState(false);
2026-05-27 15:10:04 +08:00
const [volumeDirectConfirmIdx, setVolumeDirectConfirmIdx] = useState<1 | 2 | null>(null);
const pageTitle = usePageTitleWithConnection(state?.title ?? "");
2026-05-27 15:10:04 +08:00
const volumeDirectConfirmText = useMemo((): DacVolumeDirectConfirmLocale => {
const pack = resolveLocalePack(deviceState?.language);
return (pack.dac?.dacVolumeDirectConfirm ?? localeEn.dac!.dacVolumeDirectConfirm) as DacVolumeDirectConfirmLocale;
2026-05-27 15:10:04 +08:00
}, [deviceState?.language]);
// Re-read global state whenever we enter /select (avoids stale key/options after remount)
useEffect(() => {
if (location === "/select" && selectPageState) {
setState({ ...selectPageState });
}
}, [location]);
if (!state) {
return null;
}
const { title, options, selected: selectedIdx, back, key } = state;
2026-05-27 15:10:04 +08:00
const commitSelect = async (idx: number) => {
if (saving) return;
selectPageResult = { key, selected: idx };
const apiField = KEY_TO_SETTING[key];
if (apiField) {
setSaving(true);
try {
await updateSetting({ [apiField]: idx });
} finally {
setSaving(false);
}
}
selectPageState = null;
setLocation(back);
};
2026-05-27 15:10:04 +08:00
const handleSelect = async (idx: number) => {
if (
key === "dacVolumeDirect" &&
idx !== selectedIdx &&
(idx === 1 || idx === 2)
) {
setVolumeDirectConfirmIdx(idx);
return;
}
await commitSelect(idx);
};
const volumeDirectDialogCopy =
volumeDirectConfirmIdx === 1
? {
title: volumeDirectConfirmText.title0db ?? "开启 0dB 直通模式?",
desc:
volumeDirectConfirmText.desc0db ??
"直通模式会将音量调到最大,并且不可调节。",
}
: volumeDirectConfirmIdx === 2
? {
title: volumeDirectConfirmText.title12db ?? "开启 -12dB 直通模式?",
desc:
volumeDirectConfirmText.desc12db ??
"直通模式会将音量调到 -12dB,并且不可调节。",
}
: null;
return (
<div className="min-h-screen bg-black">
{/* ── Header ── */}
<div className="page-header">
<button
onClick={() => setLocation(back)}
className="mr-4 text-white/60 active:text-white transition-colors">
<ChevronLeft size={24} />
</button>
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">{pageTitle}</h1>
<div className="w-8" />
</div>
<div className="px-4 pt-4 pb-24">
{key === "IISMode" ? (
<div className="grid w-full grid-cols-2 gap-3">
{options.map((opt, idx) => {
const isSelected = idx === selectedIdx;
return (
<button
key={idx}
type="button"
disabled={saving}
className={cn(
"flex flex-col items-center gap-2 rounded-xl border-2 p-1.5 transition-all active:scale-[0.98] disabled:opacity-60",
isSelected
? "border-[#00FFF6] shadow-[0_0_14px_rgba(0,255,246,0.35)]"
: "border-white/10",
)}
onClick={() => void handleSelect(idx)}
aria-pressed={isSelected}
aria-label={opt}
>
<img
src={iisModeImageUrl(idx)}
alt={opt}
className="block w-full h-auto rounded-lg bg-black/40"
loading="lazy"
/>
<span
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center shrink-0",
isSelected
? "bg-[#00FFF6] text-black shadow-[0_0_10px_rgba(0,255,246,0.45)]"
: "border-2 border-white/20",
)}
>
{isSelected && <Check size={12} strokeWidth={3} />}
</span>
</button>
);
})}
</div>
) : (
<div className="ios-list-group">
{options.map((opt, idx) => {
const isSelected = idx === selectedIdx;
return (
<button
key={idx}
className="ios-list-row w-full text-left active:bg-white/5 transition-colors"
onClick={() => void handleSelect(idx)}
disabled={saving}
>
<span className={`flex-1 text-[16px] ${isSelected ? "text-white font-medium" : "text-white/75"}`}>
{opt}
</span>
{isSelected && (
<Check size={18} style={{ color: "#00FFF6" }} />
)}
</button>
);
})}
</div>
)}
</div>
2026-05-27 15:10:04 +08:00
<AlertDialog
open={volumeDirectConfirmIdx !== null}
onOpenChange={(open) => {
if (!open) setVolumeDirectConfirmIdx(null);
}}
>
<AlertDialogContent className="top-[42%] border-white/10 bg-zinc-900 text-white sm:max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">{volumeDirectDialogCopy?.title}</AlertDialogTitle>
<AlertDialogDescription className="text-white/60">
{volumeDirectDialogCopy?.desc}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-white/20 bg-transparent text-white hover:bg-white/10">
{volumeDirectConfirmText.cancel ?? "取消"}
</AlertDialogCancel>
<AlertDialogAction
className="bg-[#00FFF6] text-black hover:bg-[#00FFF6]/90 focus-visible:ring-[#00FFF6]"
onClick={() => {
const idx = volumeDirectConfirmIdx;
setVolumeDirectConfirmIdx(null);
if (idx !== null) void commitSelect(idx);
}}
>
{volumeDirectConfirmText.confirm ?? "确认"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}