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

594 lines
9.9 KiB
TypeScript
Raw Normal View History

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