Update locale files for English, Traditional Chinese, and Simplified Chinese to add new confirmation prompts for volume adjustments; refactor Home and EQPage components to integrate volume confirmation dialogs and enhance user interaction for setting volume levels.

This commit is contained in:
yangy
2026-05-26 10:20:05 +08:00
parent c34571392d
commit 8fcad50e7d
6 changed files with 536 additions and 75 deletions
+251 -16
View File
@@ -91,6 +91,77 @@ function CyanSlider({ value, min, max, step = 1, onChange, disabled = false }: {
/* ── Constants ── */
const FILTER_TYPES = ["LPF", "HPF", "BPF", "NOTCH", "PEAK", "LSHELF", "HSHELF", "APF"];
const BAND_FREQ_MIN = 20;
const BAND_FREQ_MAX = 20000;
const BAND_GAIN_MIN = -15;
const BAND_GAIN_MAX = 15;
const BAND_Q_MIN = 0.1;
const BAND_Q_MAX = 10;
type BandParamKind = "freq" | "gain" | "q";
const BAND_PARAM_VALUE_BOX_STYLE: React.CSSProperties = {
background: "rgba(44,44,46,0.9)",
border: "1px solid rgba(255,255,255,0.08)",
};
function formatBandFreqDisplay(freq: number) {
return freq >= 1000
? `${(freq / 1000).toFixed(2).replace(/\.?0+$/, "")} kHz`
: `${freq} Hz`;
}
function formatBandParamForInput(kind: BandParamKind, band: { freq: number; gain: number; q: number }) {
switch (kind) {
case "freq":
return String(band.freq);
case "gain":
return band.gain.toFixed(1);
case "q":
return band.q.toFixed(2);
}
}
function parseBandParamInput(
kind: BandParamKind,
raw: string,
messages: { invalid: string; outOfRange: string },
): { ok: true; value: number } | { ok: false; message: string } {
const trimmed = raw.trim();
if (!trimmed) return { ok: false, message: messages.invalid };
if (kind === "freq") {
let s = trimmed.replace(/\s+/g, "").toLowerCase().replace(/hz$/, "");
const kHz = /k(hz)?$/.test(s);
if (kHz) s = s.replace(/k(hz)?$/, "");
const num = Number.parseFloat(s);
if (!Number.isFinite(num)) return { ok: false, message: messages.invalid };
const hz = Math.round(kHz ? num * 1000 : num);
if (hz < BAND_FREQ_MIN || hz > BAND_FREQ_MAX) {
return { ok: false, message: messages.outOfRange };
}
return { ok: true, value: hz };
}
if (kind === "gain") {
const s = trimmed.replace(/\s*dB\s*$/i, "").trim();
const num = Number.parseFloat(s);
if (!Number.isFinite(num)) return { ok: false, message: messages.invalid };
const gain = Number(num.toFixed(1));
if (gain < BAND_GAIN_MIN || gain > BAND_GAIN_MAX) {
return { ok: false, message: messages.outOfRange };
}
return { ok: true, value: gain };
}
const num = Number.parseFloat(trimmed);
if (!Number.isFinite(num)) return { ok: false, message: messages.invalid };
const q = Number(num.toFixed(2));
if (q < BAND_Q_MIN || q > BAND_Q_MAX) {
return { ok: false, message: messages.outOfRange };
}
return { ok: true, value: q };
}
function normalizeFilterType(type: string | number | undefined): string {
if (type === undefined || type === null) return "PEAK";
@@ -642,6 +713,8 @@ export default function EQPage() {
const [flatPresetName, setFlatPresetName] = useState("");
const [isSaveBDialogOpen, setIsSaveBDialogOpen] = useState(false);
const [saveBPresetName, setSaveBPresetName] = useState("");
const [bandParamDialog, setBandParamDialog] = useState<BandParamKind | null>(null);
const [bandParamInput, setBandParamInput] = useState("");
const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false);
const [batchEditText, setBatchEditText] = useState("");
type BrandDrawerTab = "brands" | "models" | "target";
@@ -1547,6 +1620,81 @@ export default function EQPage() {
setBands(nextBands);
};
const bandParamDialogMeta = useMemo(() => {
if (!bandParamDialog) return null;
const rangeMessages = {
invalid: eqUi.paramInputInvalid ?? "请输入有效数值",
outOfRange: "",
};
switch (bandParamDialog) {
case "freq":
return {
title: eqUi.paramInputTitleFreq ?? "设置频率",
hint: eqUi.paramInputHintFreq ?? "范围:20 20000 Hz,可使用 k 表示 kHz(如 9.5k",
placeholder: "9500",
inputMode: "decimal" as const,
rangeMessages: {
...rangeMessages,
outOfRange: eqInterp(eqUi.paramInputOutOfRangeFreq, {
min: String(BAND_FREQ_MIN),
max: String(BAND_FREQ_MAX),
}) || `频率需在 ${BAND_FREQ_MIN} ${BAND_FREQ_MAX} Hz 之间`,
},
};
case "gain":
return {
title: eqUi.paramInputTitleGain ?? "设置增益",
hint: eqUi.paramInputHintGain ?? "范围:-15.0 +15.0 dB",
placeholder: "0.0",
inputMode: "decimal" as const,
rangeMessages: {
...rangeMessages,
outOfRange: eqInterp(eqUi.paramInputOutOfRangeGain, {
min: BAND_GAIN_MIN.toFixed(1),
max: BAND_GAIN_MAX.toFixed(1),
}) || `增益需在 ${BAND_GAIN_MIN} ${BAND_GAIN_MAX} dB 之间`,
},
};
case "q":
return {
title: eqUi.paramInputTitleQ ?? "设置 Q 值",
hint: eqUi.paramInputHintQ ?? "范围:0.10 10.00",
placeholder: "1.41",
inputMode: "decimal" as const,
rangeMessages: {
...rangeMessages,
outOfRange: eqInterp(eqUi.paramInputOutOfRangeQ, {
min: BAND_Q_MIN.toFixed(2),
max: BAND_Q_MAX.toFixed(2),
}) || `Q 值需在 ${BAND_Q_MIN} ${BAND_Q_MAX} 之间`,
},
};
}
}, [bandParamDialog, eqUi]);
const openBandParamDialog = (kind: BandParamKind) => {
setBandParamDialog(kind);
setBandParamInput(formatBandParamForInput(kind, band));
};
const closeBandParamDialog = () => {
setBandParamDialog(null);
setBandParamInput("");
};
const applyBandParamDialog = () => {
if (!bandParamDialog || !bandParamDialogMeta) return;
const parsed = parseBandParamInput(bandParamDialog, bandParamInput, bandParamDialogMeta.rangeMessages);
if (!parsed.ok) {
toast.error(parsed.message);
return;
}
if (bandParamDialog === "freq") updateBand(selectedBand, { freq: parsed.value });
else if (bandParamDialog === "gain") updateBand(selectedBand, { gain: parsed.value });
else updateBand(selectedBand, { q: parsed.value });
closeBandParamDialog();
};
const updateCurrentPeqMeta = (patch: Partial<{ autoPre: number; preamp: number }>) => {
let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined;
setPeqItems((prev) => {
@@ -1799,39 +1947,66 @@ export default function EQPage() {
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[13px] text-white/45 font-medium tracking-wider">FREQ</span>
<span className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white"
style={{ background: "rgba(44,44,46,0.9)", border: "1px solid rgba(255,255,255,0.08)" }}>
{band.freq >= 1000 ? `${(band.freq / 1000).toFixed(2).replace(/\.?0+$/, "")} kHz` : `${band.freq} Hz`}
</span>
<button
type="button"
onClick={() => openBandParamDialog("freq")}
className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
style={BAND_PARAM_VALUE_BOX_STYLE}
>
{formatBandFreqDisplay(band.freq)}
</button>
</div>
<CyanSlider value={Math.log10(band.freq)} min={Math.log10(20)} max={Math.log10(20000)} step={0.005}
onChange={(v) => updateBand(selectedBand, { freq: Math.round(Math.pow(10, v)) })} />
<CyanSlider
value={Math.log10(band.freq)}
min={Math.log10(BAND_FREQ_MIN)}
max={Math.log10(BAND_FREQ_MAX)}
step={0.005}
onChange={(v) => updateBand(selectedBand, { freq: Math.round(Math.pow(10, v)) })}
/>
</div>
{/* GAIN */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[13px] text-white/45 font-medium tracking-wider">GAIN</span>
<span className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white"
style={{ background: "rgba(44,44,46,0.9)", border: "1px solid rgba(255,255,255,0.08)" }}>
<button
type="button"
onClick={() => openBandParamDialog("gain")}
className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
style={BAND_PARAM_VALUE_BOX_STYLE}
>
{band.gain >= 0 ? `+${band.gain.toFixed(1)}` : band.gain.toFixed(1)} dB
</span>
</button>
</div>
<CyanSlider value={band.gain} min={-15} max={15} step={0.1}
onChange={(v) => updateBand(selectedBand, { gain: v })} />
<CyanSlider
value={band.gain}
min={BAND_GAIN_MIN}
max={BAND_GAIN_MAX}
step={0.1}
onChange={(v) => updateBand(selectedBand, { gain: v })}
/>
</div>
{/* Q 值 */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[13px] text-white/45 font-medium">{peqCardLabels.q ?? "Q值"}</span>
<span className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white"
style={{ background: "rgba(44,44,46,0.9)", border: "1px solid rgba(255,255,255,0.08)" }}>
<button
type="button"
onClick={() => openBandParamDialog("q")}
className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white active:scale-95 transition-transform"
style={BAND_PARAM_VALUE_BOX_STYLE}
>
{band.q.toFixed(2)}
</span>
</button>
</div>
<CyanSlider value={band.q} min={0.1} max={10} step={0.01}
onChange={(v) => updateBand(selectedBand, { q: v })} />
<CyanSlider
value={band.q}
min={BAND_Q_MIN}
max={BAND_Q_MAX}
step={0.01}
onChange={(v) => updateBand(selectedBand, { q: v })}
/>
</div>
</div>
@@ -1865,6 +2040,66 @@ export default function EQPage() {
</FeatureGate>
</div>
{bandParamDialog && bandParamDialogMeta && (
<div
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4"
onClick={closeBandParamDialog}
>
<div
className="w-full max-w-[420px] rounded-[14px] p-5"
style={{
background: "linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)",
border: "1px solid rgba(255,255,255,0.12)",
boxShadow: "0 18px 48px rgba(0,0,0,0.55)",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-2">
<h3 className="text-[18px] font-semibold leading-tight text-white/90">{bandParamDialogMeta.title}</h3>
<button
type="button"
className="rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80"
onClick={closeBandParamDialog}
aria-label={eqUi.closeDrawer}
>
<X size={20} />
</button>
</div>
<p className="mb-4 text-[13px] leading-relaxed text-white/45">{bandParamDialogMeta.hint}</p>
<input
value={bandParamInput}
onChange={(e) => setBandParamInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyBandParamDialog();
}
}}
inputMode={bandParamDialogMeta.inputMode}
placeholder={bandParamDialogMeta.placeholder}
className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40"
autoFocus
/>
<div className="mt-6 flex items-center justify-center gap-4 sm:gap-8">
<button
type="button"
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-medium text-white/85 transition-colors bg-[#3f4349] hover:bg-[#4a4f56] active:scale-[0.98]"
onClick={closeBandParamDialog}
>
{eqUi.cancel}
</button>
<button
type="button"
className="min-w-[112px] rounded-full px-5 py-2.5 text-[15px] font-semibold text-black transition-all bg-[#00FFF6] hover:brightness-95 active:scale-[0.98]"
onClick={applyBandParamDialog}
>
{eqUi.confirmButton ?? eqUi.save}
</button>
</div>
</div>
</div>
)}
{isSaveBDialogOpen && (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4">
<div
+72 -5
View File
@@ -172,6 +172,7 @@ export default function Home() {
const effectLastTapRef = useRef<number | null>(null);
const effectNavigateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [powerConfirmOpen, setPowerConfirmOpen] = useState(false);
const [volumeConfirm, setVolumeConfirm] = useState<null | "min" | "max">(null);
const [easterEggActive, setEasterEggActive] = useState(false);
const [vuHeights, setVuHeights] = useState<number[]>(idleVuBarHeights);
const jackProgressRef = useRef(0);
@@ -187,6 +188,28 @@ export default function Home() {
return (loc.home ?? {}) as HomeLocale;
}, [deviceState]);
const volumeConfirmCopy = useMemo(() => {
if (volumeConfirm === "min") {
return {
title: homeText.volumeMinConfirmTitle ?? "确认将音量设为最小?",
desc:
homeText.volumeMinConfirmDesc ??
"耳机音量将调至最低(静音),是否继续?",
ok: homeText.volumeMinConfirmOk ?? "设为最小",
};
}
if (volumeConfirm === "max") {
return {
title: homeText.volumeMaxConfirmTitle ?? "确认将音量设为最大?",
desc:
homeText.volumeMaxConfirmDesc ??
"耳机音量将调至最大,是否继续?",
ok: homeText.volumeMaxConfirmOk ?? "设为最大",
};
}
return null;
}, [volumeConfirm, homeText]);
const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100);
const isDragging = useRef(false);
const knobDraggingRef = useRef(false);
@@ -562,8 +585,11 @@ export default function Home() {
{/* ── Volume slider ── */}
<div className="px-4 mb-5">
<div className="flex items-center gap-3">
<button onClick={() => { setLocalVol(0); setVolume(0); }}
className="text-white/50 active:text-white transition-colors flex-shrink-0">
<button
type="button"
onClick={() => setVolumeConfirm("min")}
className="text-white/50 active:text-white transition-colors flex-shrink-0"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-6 h-6">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
@@ -618,8 +644,11 @@ export default function Home() {
/>
</div>
<button onClick={() => { setLocalVol(200); setVolume(200); }}
className="text-white/50 active:text-white transition-colors flex-shrink-0">
<button
type="button"
onClick={() => setVolumeConfirm("max")}
className="text-white/50 active:text-white transition-colors flex-shrink-0"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-6 h-6">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
@@ -824,8 +853,46 @@ export default function Home() {
</div>
</div>
<AlertDialog
open={volumeConfirm !== null}
onOpenChange={(open) => {
if (!open) setVolumeConfirm(null);
}}
>
<AlertDialogContent className="top-[42%] border-white/10 bg-zinc-900 text-white sm:max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">
{volumeConfirmCopy?.title}
</AlertDialogTitle>
<AlertDialogDescription className="text-white/60">
{volumeConfirmCopy?.desc}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-white/20 bg-transparent text-white hover:bg-white/10">
{homeText.powerOffConfirmCancel ?? "取消"}
</AlertDialogCancel>
<AlertDialogAction
className="bg-[#00FFF6] text-black hover:bg-[#00FFF6]/90 focus-visible:ring-[#00FFF6]"
onClick={() => {
if (volumeConfirm === "min") {
setLocalVol(0);
setVolume(0);
} else if (volumeConfirm === "max") {
setLocalVol(200);
setVolume(200);
}
setVolumeConfirm(null);
}}
>
{volumeConfirmCopy?.ok}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={powerConfirmOpen} onOpenChange={setPowerConfirmOpen}>
<AlertDialogContent className="border-white/10 bg-zinc-900 text-white sm:max-w-md">
<AlertDialogContent className="top-[42%] border-white/10 bg-zinc-900 text-white sm:max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">
{homeText.powerOffConfirmTitle ?? "确认关闭电源?"}
+148 -49
View File
@@ -15,82 +15,156 @@ import localeZh from "@/locales/data-zh.json";
import localeZhHK from "@/locales/data-zh-HK.json";
import localeEn from "@/locales/data-en.json";
type SourceLocale = NonNullable<(typeof localeZh)["source"]>;
interface IOOption {
index: number;
label: string;
icon: React.ReactNode;
}
const InputIcon = ({ d }: { d: string }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7">
<path d={d} />
</svg>
);
interface IOOptionDef {
index: number;
icon: React.ReactNode;
}
const INPUT_OPTIONS: IOOption[] = [
const INPUT_OPTION_DEFS: IOOptionDef[] = [
{
index: 0, label: "USB-B",
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7"><rect x="8" y="6" width="8" height="12" rx="1.5"/><line x1="10" y1="6" x2="10" y2="4"/><line x1="14" y1="6" x2="14" y2="4"/></svg>
index: 0,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7">
<rect x="8" y="6" width="8" height="12" rx="1.5" />
<line x1="10" y1="6" x2="10" y2="4" />
<line x1="14" y1="6" x2="14" y2="4" />
</svg>
),
},
{
index: 1, label: "USB-C",
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7"><rect x="5" y="9" width="14" height="6" rx="3"/></svg>
index: 1,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7">
<rect x="5" y="9" width="14" height="6" rx="3" />
</svg>
),
},
{
index: 2, label: "同轴",
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>
index: 2,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7">
<circle cx="12" cy="12" r="8" />
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none" />
</svg>
),
},
{
index: 3, label: "光纤",
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7"><rect x="7" y="7" width="10" height="10" rx="1"/><line x1="12" y1="3" x2="12" y2="7"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
index: 3,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7">
<rect x="7" y="7" width="10" height="10" rx="1" />
<line x1="12" y1="3" x2="12" y2="7" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
),
},
{
index: 4, label: "蓝牙",
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7"><polyline points="6.5 6.5 17.5 17.5 12 23 12 1 17.5 6.5 6.5 17.5"/></svg>
index: 4,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7">
<polyline points="6.5 6.5 17.5 17.5 12 23 12 1 17.5 6.5 6.5 17.5" />
</svg>
),
},
{
index: 5, label: "IIS",
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7"><rect x="3" y="8" width="18" height="10" rx="1.5"/><line x1="7" y1="8" x2="7" y2="18"/><line x1="12" y1="8" x2="12" y2="18"/><line x1="17" y1="8" x2="17" y2="18"/></svg>
index: 5,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7">
<rect x="3" y="8" width="18" height="10" rx="1.5" />
<line x1="7" y1="8" x2="7" y2="18" />
<line x1="12" y1="8" x2="12" y2="18" />
<line x1="17" y1="8" x2="17" y2="18" />
</svg>
),
},
];
const OUTPUT_OPTIONS: IOOption[] = [
const OUTPUT_OPTION_DEFS: IOOptionDef[] = [
{
index: 0, label: "XLR",
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7"><circle cx="12" cy="12" r="8"/><circle cx="9" cy="10" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="10" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="15" r="1.5" fill="currentColor" stroke="none"/></svg>
index: 0,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7">
<circle cx="12" cy="12" r="8" />
<circle cx="9" cy="10" r="1.5" fill="currentColor" stroke="none" />
<circle cx="15" cy="10" r="1.5" fill="currentColor" stroke="none" />
<circle cx="12" cy="15" r="1.5" fill="currentColor" stroke="none" />
</svg>
),
},
{
index: 1, label: "RCA",
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>
index: 1,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7">
<circle cx="12" cy="12" r="8" />
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none" />
</svg>
),
},
{
index: 2, label: "耳机",
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3z"/><path d="M3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg>
index: 2,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7">
<path d="M3 18v-6a9 9 0 0 1 18 0v6" />
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3z" />
<path d="M3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z" />
</svg>
),
},
{
index: 3, label: "XLR/RCA",
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7"><circle cx="8" cy="12" r="5"/><circle cx="16" cy="12" r="5"/><circle cx="8" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="16" cy="12" r="1.5" fill="currentColor" stroke="none"/></svg>
index: 3,
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7">
<circle cx="8" cy="12" r="5" />
<circle cx="16" cy="12" r="5" />
<circle cx="8" cy="12" r="1.5" fill="currentColor" stroke="none" />
<circle cx="16" cy="12" r="1.5" fill="currentColor" stroke="none" />
</svg>
),
},
];
const FALLBACK_INPUT_LABELS = ["USB-B", "USB-C", "同轴", "光纤", "蓝牙", "IIS"];
const FALLBACK_OUTPUT_LABELS = ["XLR", "RCA", "耳机", "XLR/RCA"];
function withLabels(defs: IOOptionDef[], labels: string[] | undefined, fallback: string[]): IOOption[] {
return defs.map((def, i) => ({
...def,
label: labels?.[i] ?? fallback[i] ?? String(def.index),
}));
}
function IOCircleButton({
option, active, onSelect,
}: { option: IOOption; active: boolean; onSelect: () => void }) {
option,
active,
onSelect,
}: {
option: IOOption;
active: boolean;
onSelect: () => void;
}) {
return (
<button onClick={onSelect} className="flex flex-col items-center gap-2.5">
<div
className={cn(
"w-[68px] h-[68px] rounded-full flex items-center justify-center transition-all duration-200",
active
? "text-black"
: "text-white/55 border border-white/10"
active ? "text-black" : "text-white/55 border border-white/10",
)}
style={active ? {
background: "#00FFF6",
boxShadow: "0 0 24px rgba(0,255,246,0.55), 0 0 8px rgba(0,255,246,0.3)",
} : {
background: "rgba(44,44,46,0.9)",
}}
style={
active
? {
background: "#00FFF6",
boxShadow: "0 0 24px rgba(0,255,246,0.55), 0 0 8px rgba(0,255,246,0.3)",
}
: { background: "rgba(44,44,46,0.9)" }
}
>
{option.icon}
</div>
@@ -105,19 +179,36 @@ export default function IOPage() {
const [, setLocation] = useLocation();
const { deviceState, setInput, setOutput } = useDevice();
const ioPageTitle = useMemo(() => {
const sourceText = useMemo((): SourceLocale => {
const lang = deviceState?.language;
const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
return pack.source?.pageTitle ?? pack.source?.label ?? "输入输出";
return (pack.source ?? {}) as SourceLocale;
}, [deviceState?.language]);
const inputOptions = useMemo(
() => withLabels(INPUT_OPTION_DEFS, sourceText.inputOptions, FALLBACK_INPUT_LABELS),
[sourceText.inputOptions],
);
const outputOptions = useMemo(
() => withLabels(OUTPUT_OPTION_DEFS, sourceText.outputOptions, FALLBACK_OUTPUT_LABELS),
[sourceText.outputOptions],
);
const ioPageTitle = sourceText.pageTitle ?? sourceText.label ?? "输入输出";
const inputSectionTitle = sourceText.inputSection ?? "输入";
const outputSectionTitle = sourceText.outputSection ?? "输出";
const currentInput = deviceState?.input ?? 1;
const currentOutput = deviceState?.output ?? 2;
return (
<div className="min-h-screen bg-black">
<div className="page-header">
<button onClick={() => setLocation("/")} className="mr-4 text-white/60 active:text-white transition-colors">
<button
onClick={() => setLocation("/")}
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">{ioPageTitle}</h1>
@@ -126,24 +217,32 @@ export default function IOPage() {
<div className="px-4 pb-32 pt-4 space-y-6">
<div>
<p className="text-[22px] font-bold text-white mb-4 px-1">Input</p>
<p className="text-[22px] font-bold text-white mb-4 px-1">{inputSectionTitle}</p>
<div className="ios-list-group p-5">
<div className="grid grid-cols-3 gap-x-2 gap-y-6">
{INPUT_OPTIONS.map((opt) => (
<IOCircleButton key={opt.index} option={opt} active={currentInput === opt.index}
onSelect={() => setInput(opt.index)} />
{inputOptions.map((opt) => (
<IOCircleButton
key={opt.index}
option={opt}
active={currentInput === opt.index}
onSelect={() => setInput(opt.index)}
/>
))}
</div>
</div>
</div>
<div>
<p className="text-[22px] font-bold text-white mb-4 px-1">Output</p>
<p className="text-[22px] font-bold text-white mb-4 px-1">{outputSectionTitle}</p>
<div className="ios-list-group p-5">
<div className="grid grid-cols-3 gap-x-2 gap-y-6">
{OUTPUT_OPTIONS.map((opt) => (
<IOCircleButton key={opt.index} option={opt} active={currentOutput === opt.index}
onSelect={() => setOutput(opt.index)} />
{outputOptions.map((opt) => (
<IOCircleButton
key={opt.index}
option={opt}
active={currentOutput === opt.index}
onSelect={() => setOutput(opt.index)}
/>
))}
</div>
</div>