2026-03-31 18:52:37 +08:00
|
|
|
/* ============================================================
|
|
|
|
|
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";
|
2026-05-25 16:30:40 +08:00
|
|
|
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";
|
2026-07-01 11:23:06 +08:00
|
|
|
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"]
|
|
|
|
|
>;
|
2026-03-31 18:52:37 +08:00
|
|
|
|
2026-05-25 16:30:40 +08:00
|
|
|
const iisModeImageUrl = (index: number) =>
|
2026-06-18 16:12:51 +08:00
|
|
|
`${import.meta.env.BASE_URL}images/IIS/iis_app${index + 1}.png`;
|
2026-05-25 16:30:40 +08:00
|
|
|
|
2026-03-31 18:52:37 +08:00
|
|
|
/* ── Key → API field mapping ── */
|
|
|
|
|
const KEY_TO_SETTING: Record<string, string> = {
|
|
|
|
|
effect_value: "effect_value",
|
|
|
|
|
crossfeed_value: "crossfeed_value",
|
2026-09-09 18:26:28 +08:00
|
|
|
hearing_select: "hearing_select",
|
2026-03-31 18:52:37 +08:00
|
|
|
language: "language",
|
|
|
|
|
analogGain: "analogGain",
|
|
|
|
|
soundStep: "soundStep",
|
2026-05-18 13:42:29 +08:00
|
|
|
bootSound: "bootSound",
|
2026-04-17 17:57:39 +08:00
|
|
|
filterCharacteristic: "pcm",
|
|
|
|
|
mutePolar: "hdmimutepolar",
|
|
|
|
|
IISMode: "hdmiType",
|
2026-03-31 18:52:37 +08:00
|
|
|
xlr: "xlr",
|
|
|
|
|
dacGain: "dacGain",
|
|
|
|
|
dacImpedance: "dacImpedance",
|
2026-05-27 15:10:04 +08:00
|
|
|
dacVolumeDirect: "dacVolumeDirect",
|
2026-09-08 14:19:26 +08:00
|
|
|
uacVer: "uac_ver",
|
2026-03-31 18:52:37 +08:00
|
|
|
vu: "vu",
|
|
|
|
|
input: "input",
|
|
|
|
|
output: "output",
|
|
|
|
|
screenLight: "screenLight",
|
|
|
|
|
screenOff: "screenOff",
|
|
|
|
|
buttonLight: "buttonLight",
|
|
|
|
|
autoHome: "autoHome",
|
|
|
|
|
sleep: "sleep",
|
2026-04-01 16:29:52 +08:00
|
|
|
headphone: "peqSelect",
|
2026-03-31 18:52:37 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Global state for select page (to avoid URL params)
|
|
|
|
|
export let selectPageState: {
|
|
|
|
|
title: string;
|
|
|
|
|
options: string[];
|
|
|
|
|
selected: number;
|
|
|
|
|
back: string;
|
|
|
|
|
key: string;
|
|
|
|
|
} | null = null;
|
|
|
|
|
|
2026-04-17 17:57:39 +08:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 18:26:28 +08:00
|
|
|
// Scroll position of the calling page, captured before navigating to /select.
|
|
|
|
|
// The select page is short, so the browser clamps window scroll to 0 while on
|
|
|
|
|
// it; without restoring, the calling page (e.g. Effects) reopens at the top.
|
|
|
|
|
let selectReturnScroll: number | null = null;
|
|
|
|
|
|
|
|
|
|
/** Consume the stored scroll position (once). Returns null when absent. */
|
|
|
|
|
export function consumeSelectReturnScroll(): number | null {
|
|
|
|
|
const y = selectReturnScroll;
|
|
|
|
|
selectReturnScroll = null;
|
|
|
|
|
return y;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:52:37 +08:00
|
|
|
// Helper function to set select page state
|
|
|
|
|
export function navigateToSelect(title: string, options: string[], selected: number, back: string, key: string) {
|
2026-09-09 18:26:28 +08:00
|
|
|
selectReturnScroll = window.scrollY;
|
2026-03-31 18:52:37 +08:00
|
|
|
selectPageState = { title, options, selected, back, key };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function SelectPage() {
|
2026-05-18 13:42:29 +08:00
|
|
|
const [location, setLocation] = useLocation();
|
2026-05-27 15:10:04 +08:00
|
|
|
const { updateSetting, deviceState } = useDevice();
|
2026-03-31 18:52:37 +08:00
|
|
|
const [state, setState] = useState<{
|
|
|
|
|
title: string;
|
|
|
|
|
options: string[];
|
|
|
|
|
selected: number;
|
|
|
|
|
back: string;
|
|
|
|
|
key: string;
|
2026-05-18 13:42:29 +08:00
|
|
|
} | null>(() => selectPageState);
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
2026-05-27 15:10:04 +08:00
|
|
|
const [volumeDirectConfirmIdx, setVolumeDirectConfirmIdx] = useState<1 | 2 | null>(null);
|
|
|
|
|
|
2026-07-01 11:23:06 +08:00
|
|
|
const pageTitle = usePageTitleWithConnection(state?.title ?? "");
|
|
|
|
|
|
2026-05-27 15:10:04 +08:00
|
|
|
const volumeDirectConfirmText = useMemo((): DacVolumeDirectConfirmLocale => {
|
2026-07-01 11:23:06 +08:00
|
|
|
const pack = resolveLocalePack(deviceState?.language);
|
|
|
|
|
return (pack.dac?.dacVolumeDirectConfirm ?? localeEn.dac!.dacVolumeDirectConfirm) as DacVolumeDirectConfirmLocale;
|
2026-05-27 15:10:04 +08:00
|
|
|
}, [deviceState?.language]);
|
2026-03-31 18:52:37 +08:00
|
|
|
|
2026-05-18 13:42:29 +08:00
|
|
|
// Re-read global state whenever we enter /select (avoids stale key/options after remount)
|
2026-03-31 18:52:37 +08:00
|
|
|
useEffect(() => {
|
2026-05-18 13:42:29 +08:00
|
|
|
if (location === "/select" && selectPageState) {
|
|
|
|
|
setState({ ...selectPageState });
|
|
|
|
|
}
|
|
|
|
|
}, [location]);
|
2026-03-31 18:52:37 +08:00
|
|
|
|
|
|
|
|
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) => {
|
2026-05-18 13:42:29 +08:00
|
|
|
if (saving) return;
|
2026-04-17 17:57:39 +08:00
|
|
|
selectPageResult = { key, selected: idx };
|
|
|
|
|
|
2026-03-31 18:52:37 +08:00
|
|
|
const apiField = KEY_TO_SETTING[key];
|
|
|
|
|
if (apiField) {
|
2026-05-18 13:42:29 +08:00
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
await updateSetting({ [apiField]: idx });
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
2026-03-31 18:52:37 +08:00
|
|
|
}
|
2026-05-18 13:42:29 +08:00
|
|
|
selectPageState = null;
|
2026-03-31 18:52:37 +08:00
|
|
|
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;
|
|
|
|
|
|
2026-03-31 18:52:37 +08:00
|
|
|
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>
|
2026-07-01 11:23:06 +08:00
|
|
|
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">{pageTitle}</h1>
|
2026-03-31 18:52:37 +08:00
|
|
|
<div className="w-8" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="px-4 pt-4 pb-24">
|
2026-05-25 16:30:40 +08:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-03-31 18:52:37 +08:00
|
|
|
</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>
|
2026-03-31 18:52:37 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|