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:
+251
-16
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user