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
+22 -2
View File
@@ -18,6 +18,12 @@
"powerOffConfirmDesc": "The device will shut down and disconnect from this app. Do you want to continue?", "powerOffConfirmDesc": "The device will shut down and disconnect from this app. Do you want to continue?",
"powerOffConfirmOk": "Turn off", "powerOffConfirmOk": "Turn off",
"powerOffConfirmCancel": "Cancel", "powerOffConfirmCancel": "Cancel",
"volumeMinConfirmTitle": "Set volume to minimum?",
"volumeMinConfirmDesc": "Headphone volume will be set to the lowest level (mute). Continue?",
"volumeMinConfirmOk": "Set to minimum",
"volumeMaxConfirmTitle": "Set volume to maximum?",
"volumeMaxConfirmDesc": "Headphone volume will be set to the highest level. Continue?",
"volumeMaxConfirmOk": "Set to maximum",
"powerOffToastDemo": "Demo mode: disconnected from the device.", "powerOffToastDemo": "Demo mode: disconnected from the device.",
"powerOffToastSent": "Shutdown command sent.", "powerOffToastSent": "Shutdown command sent.",
"doubleClickHint": "Double-click to enter", "doubleClickHint": "Double-click to enter",
@@ -43,6 +49,10 @@
"source": { "source": {
"label": "Input/Output", "label": "Input/Output",
"pageTitle": "I/O", "pageTitle": "I/O",
"inputSection": "Input",
"outputSection": "Output",
"inputOptions": ["USB-B", "USB-C", "Coaxial", "Optical", "Bluetooth", "IIS"],
"outputOptions": ["XLR", "RCA", "Headset", "XLR/RCA"],
"input": { "input": {
"coaxial": "COAXIAL", "coaxial": "COAXIAL",
"optical": "OPTICAL", "optical": "OPTICAL",
@@ -50,7 +60,7 @@
"rca": "RCA" "rca": "RCA"
}, },
"output": { "output": {
"headset": "HeadSet" "headset": "Headset"
} }
}, },
"effect": { "effect": {
@@ -306,7 +316,17 @@
"toastSaveBFail": "Failed to save curve B", "toastSaveBFail": "Failed to save curve B",
"enableConfirmTitle": "HP-EQ is off", "enableConfirmTitle": "HP-EQ is off",
"enableConfirmDesc": "HP-EQ is currently disabled. Turn it on?", "enableConfirmDesc": "HP-EQ is currently disabled. Turn it on?",
"enableConfirmOk": "Enable" "enableConfirmOk": "Enable",
"paramInputTitleFreq": "Set frequency",
"paramInputTitleGain": "Set gain",
"paramInputTitleQ": "Set Q value",
"paramInputHintFreq": "Range: 20 20000 Hz. Use k for kHz (e.g. 9.5k).",
"paramInputHintGain": "Range: -15.0 +15.0 dB",
"paramInputHintQ": "Range: 0.10 10.00",
"paramInputInvalid": "Enter a valid number",
"paramInputOutOfRangeFreq": "Frequency must be between {{min}} and {{max}} Hz",
"paramInputOutOfRangeGain": "Gain must be between {{min}} and {{max}} dB",
"paramInputOutOfRangeQ": "Q must be between {{min}} and {{max}}"
} }
}, },
"dac": { "dac": {
+21 -1
View File
@@ -18,6 +18,12 @@
"powerOffConfirmDesc": "裝置將關機並中斷與本應用程式的連線,是否繼續?", "powerOffConfirmDesc": "裝置將關機並中斷與本應用程式的連線,是否繼續?",
"powerOffConfirmOk": "關閉電源", "powerOffConfirmOk": "關閉電源",
"powerOffConfirmCancel": "取消", "powerOffConfirmCancel": "取消",
"volumeMinConfirmTitle": "確認將音量設為最小?",
"volumeMinConfirmDesc": "耳機音量將調至最低(靜音),是否繼續?",
"volumeMinConfirmOk": "設為最小",
"volumeMaxConfirmTitle": "確認將音量設為最大?",
"volumeMaxConfirmDesc": "耳機音量將調至最大,是否繼續?",
"volumeMaxConfirmOk": "設為最大",
"powerOffToastDemo": "演示模式:已中斷與裝置的連線", "powerOffToastDemo": "演示模式:已中斷與裝置的連線",
"powerOffToastSent": "已傳送關機指令", "powerOffToastSent": "已傳送關機指令",
"doubleClickHint": "按兩下進入", "doubleClickHint": "按兩下進入",
@@ -43,6 +49,10 @@
"source": { "source": {
"label": "輸入/輸出", "label": "輸入/輸出",
"pageTitle": "輸入/輸出", "pageTitle": "輸入/輸出",
"inputSection": "輸入",
"outputSection": "輸出",
"inputOptions": ["USB-B", "USB-C", "同軸", "光纖", "藍牙", "IIS"],
"outputOptions": ["XLR", "RCA", "耳機", "XLR/RCA"],
"input": { "coaxial": "同軸", "optical": "光纖", "bluetooth": "藍牙", "rca": "類比RCA" }, "input": { "coaxial": "同軸", "optical": "光纖", "bluetooth": "藍牙", "rca": "類比RCA" },
"output": { "headset": "耳機" } "output": { "headset": "耳機" }
}, },
@@ -242,7 +252,17 @@
"toastSaveBFail": "另存 B 失敗", "toastSaveBFail": "另存 B 失敗",
"enableConfirmTitle": "HP-EQ 未開啟", "enableConfirmTitle": "HP-EQ 未開啟",
"enableConfirmDesc": "目前 HP-EQ 功能已關閉,是否開啟?", "enableConfirmDesc": "目前 HP-EQ 功能已關閉,是否開啟?",
"enableConfirmOk": "開啟" "enableConfirmOk": "開啟",
"paramInputTitleFreq": "設定頻率",
"paramInputTitleGain": "設定增益",
"paramInputTitleQ": "設定 Q 值",
"paramInputHintFreq": "範圍:20 20000 Hz,可使用 k 表示 kHz(如 9.5k",
"paramInputHintGain": "範圍:-15.0 +15.0 dB",
"paramInputHintQ": "範圍:0.10 10.00",
"paramInputInvalid": "請輸入有效數值",
"paramInputOutOfRangeFreq": "頻率需在 {{min}} {{max}} Hz 之間",
"paramInputOutOfRangeGain": "增益需在 {{min}} {{max}} dB 之間",
"paramInputOutOfRangeQ": "Q 值需在 {{min}} {{max}} 之間"
} }
}, },
"dac": { "dac": {
+22 -2
View File
@@ -18,6 +18,12 @@
"powerOffConfirmDesc": "设备将关机并断开与本应用的连接,是否继续?", "powerOffConfirmDesc": "设备将关机并断开与本应用的连接,是否继续?",
"powerOffConfirmOk": "关闭电源", "powerOffConfirmOk": "关闭电源",
"powerOffConfirmCancel": "取消", "powerOffConfirmCancel": "取消",
"volumeMinConfirmTitle": "确认将音量设为最小?",
"volumeMinConfirmDesc": "耳机音量将调至最低(静音),是否继续?",
"volumeMinConfirmOk": "设为最小",
"volumeMaxConfirmTitle": "确认将音量设为最大?",
"volumeMaxConfirmDesc": "耳机音量将调至最大,是否继续?",
"volumeMaxConfirmOk": "设为最大",
"powerOffToastDemo": "演示模式:已断开与设备的连接", "powerOffToastDemo": "演示模式:已断开与设备的连接",
"powerOffToastSent": "已发送关机指令", "powerOffToastSent": "已发送关机指令",
"doubleClickHint": "双击进入", "doubleClickHint": "双击进入",
@@ -43,7 +49,11 @@
"source": { "source": {
"label": "输入输出", "label": "输入输出",
"pageTitle": "输入输出", "pageTitle": "输入输出",
"input": { "coaxial": "同轴", "optical": "光钎", "bluetooth": "蓝牙", "rca": "模拟RCA" }, "inputSection": "输入",
"outputSection": "输出",
"inputOptions": ["USB-B", "USB-C", "同轴", "光纤", "蓝牙", "IIS"],
"outputOptions": ["XLR", "RCA", "耳机", "XLR/RCA"],
"input": { "coaxial": "同轴", "optical": "光纤", "bluetooth": "蓝牙", "rca": "模拟RCA" },
"output": { "headset": "耳机" } "output": { "headset": "耳机" }
}, },
"effect": { "effect": {
@@ -242,7 +252,17 @@
"toastSaveBFail": "另存 B 失败", "toastSaveBFail": "另存 B 失败",
"enableConfirmTitle": "HP-EQ 未开启", "enableConfirmTitle": "HP-EQ 未开启",
"enableConfirmDesc": "当前 HP-EQ 功能已关闭,是否开启?", "enableConfirmDesc": "当前 HP-EQ 功能已关闭,是否开启?",
"enableConfirmOk": "开启" "enableConfirmOk": "开启",
"paramInputTitleFreq": "设置频率",
"paramInputTitleGain": "设置增益",
"paramInputTitleQ": "设置 Q 值",
"paramInputHintFreq": "范围:20 20000 Hz,可使用 k 表示 kHz(如 9.5k",
"paramInputHintGain": "范围:-15.0 +15.0 dB",
"paramInputHintQ": "范围:0.10 10.00",
"paramInputInvalid": "请输入有效数值",
"paramInputOutOfRangeFreq": "频率需在 {{min}} {{max}} Hz 之间",
"paramInputOutOfRangeGain": "增益需在 {{min}} {{max}} dB 之间",
"paramInputOutOfRangeQ": "Q 值需在 {{min}} {{max}} 之间"
} }
}, },
"dac": { "dac": {
+251 -16
View File
@@ -91,6 +91,77 @@ function CyanSlider({ value, min, max, step = 1, onChange, disabled = false }: {
/* ── Constants ── */ /* ── Constants ── */
const FILTER_TYPES = ["LPF", "HPF", "BPF", "NOTCH", "PEAK", "LSHELF", "HSHELF", "APF"]; 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 { function normalizeFilterType(type: string | number | undefined): string {
if (type === undefined || type === null) return "PEAK"; if (type === undefined || type === null) return "PEAK";
@@ -642,6 +713,8 @@ export default function EQPage() {
const [flatPresetName, setFlatPresetName] = useState(""); const [flatPresetName, setFlatPresetName] = useState("");
const [isSaveBDialogOpen, setIsSaveBDialogOpen] = useState(false); const [isSaveBDialogOpen, setIsSaveBDialogOpen] = useState(false);
const [saveBPresetName, setSaveBPresetName] = useState(""); const [saveBPresetName, setSaveBPresetName] = useState("");
const [bandParamDialog, setBandParamDialog] = useState<BandParamKind | null>(null);
const [bandParamInput, setBandParamInput] = useState("");
const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false); const [isBatchEditDialogOpen, setIsBatchEditDialogOpen] = useState(false);
const [batchEditText, setBatchEditText] = useState(""); const [batchEditText, setBatchEditText] = useState("");
type BrandDrawerTab = "brands" | "models" | "target"; type BrandDrawerTab = "brands" | "models" | "target";
@@ -1547,6 +1620,81 @@ export default function EQPage() {
setBands(nextBands); 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 }>) => { const updateCurrentPeqMeta = (patch: Partial<{ autoPre: number; preamp: number }>) => {
let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined; let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined;
setPeqItems((prev) => { setPeqItems((prev) => {
@@ -1799,39 +1947,66 @@ export default function EQPage() {
<div> <div>
<div className="flex items-center justify-between mb-1"> <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] text-white/45 font-medium tracking-wider">FREQ</span>
<span className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white" <button
style={{ background: "rgba(44,44,46,0.9)", border: "1px solid rgba(255,255,255,0.08)" }}> type="button"
{band.freq >= 1000 ? `${(band.freq / 1000).toFixed(2).replace(/\.?0+$/, "")} kHz` : `${band.freq} Hz`} onClick={() => openBandParamDialog("freq")}
</span> 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> </div>
<CyanSlider value={Math.log10(band.freq)} min={Math.log10(20)} max={Math.log10(20000)} step={0.005} <CyanSlider
onChange={(v) => updateBand(selectedBand, { freq: Math.round(Math.pow(10, v)) })} /> 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> </div>
{/* GAIN */} {/* GAIN */}
<div> <div>
<div className="flex items-center justify-between mb-1"> <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] text-white/45 font-medium tracking-wider">GAIN</span>
<span className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white" <button
style={{ background: "rgba(44,44,46,0.9)", border: "1px solid rgba(255,255,255,0.08)" }}> 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 {band.gain >= 0 ? `+${band.gain.toFixed(1)}` : band.gain.toFixed(1)} dB
</span> </button>
</div> </div>
<CyanSlider value={band.gain} min={-15} max={15} step={0.1} <CyanSlider
onChange={(v) => updateBand(selectedBand, { gain: v })} /> value={band.gain}
min={BAND_GAIN_MIN}
max={BAND_GAIN_MAX}
step={0.1}
onChange={(v) => updateBand(selectedBand, { gain: v })}
/>
</div> </div>
{/* Q 值 */} {/* Q 值 */}
<div> <div>
<div className="flex items-center justify-between mb-1"> <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] text-white/45 font-medium">{peqCardLabels.q ?? "Q值"}</span>
<span className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white" <button
style={{ background: "rgba(44,44,46,0.9)", border: "1px solid rgba(255,255,255,0.08)" }}> 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)} {band.q.toFixed(2)}
</span> </button>
</div> </div>
<CyanSlider value={band.q} min={0.1} max={10} step={0.01} <CyanSlider
onChange={(v) => updateBand(selectedBand, { q: v })} /> value={band.q}
min={BAND_Q_MIN}
max={BAND_Q_MAX}
step={0.01}
onChange={(v) => updateBand(selectedBand, { q: v })}
/>
</div> </div>
</div> </div>
@@ -1865,6 +2040,66 @@ export default function EQPage() {
</FeatureGate> </FeatureGate>
</div> </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 && ( {isSaveBDialogOpen && (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4"> <div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4">
<div <div
+72 -5
View File
@@ -172,6 +172,7 @@ export default function Home() {
const effectLastTapRef = useRef<number | null>(null); const effectLastTapRef = useRef<number | null>(null);
const effectNavigateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const effectNavigateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [powerConfirmOpen, setPowerConfirmOpen] = useState(false); const [powerConfirmOpen, setPowerConfirmOpen] = useState(false);
const [volumeConfirm, setVolumeConfirm] = useState<null | "min" | "max">(null);
const [easterEggActive, setEasterEggActive] = useState(false); const [easterEggActive, setEasterEggActive] = useState(false);
const [vuHeights, setVuHeights] = useState<number[]>(idleVuBarHeights); const [vuHeights, setVuHeights] = useState<number[]>(idleVuBarHeights);
const jackProgressRef = useRef(0); const jackProgressRef = useRef(0);
@@ -187,6 +188,28 @@ export default function Home() {
return (loc.home ?? {}) as HomeLocale; return (loc.home ?? {}) as HomeLocale;
}, [deviceState]); }, [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 [localVol, setLocalVol] = useState(deviceState?.volume ?? 100);
const isDragging = useRef(false); const isDragging = useRef(false);
const knobDraggingRef = useRef(false); const knobDraggingRef = useRef(false);
@@ -562,8 +585,11 @@ export default function Home() {
{/* ── Volume slider ── */} {/* ── Volume slider ── */}
<div className="px-4 mb-5"> <div className="px-4 mb-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button onClick={() => { setLocalVol(0); setVolume(0); }} <button
className="text-white/50 active:text-white transition-colors flex-shrink-0"> 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"> <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"/> <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"/> <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> </div>
<button onClick={() => { setLocalVol(200); setVolume(200); }} <button
className="text-white/50 active:text-white transition-colors flex-shrink-0"> 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"> <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"/> <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"/> <path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
@@ -824,8 +853,46 @@ export default function Home() {
</div> </div>
</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}> <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> <AlertDialogHeader>
<AlertDialogTitle className="text-white"> <AlertDialogTitle className="text-white">
{homeText.powerOffConfirmTitle ?? "确认关闭电源?"} {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 localeZhHK from "@/locales/data-zh-HK.json";
import localeEn from "@/locales/data-en.json"; import localeEn from "@/locales/data-en.json";
type SourceLocale = NonNullable<(typeof localeZh)["source"]>;
interface IOOption { interface IOOption {
index: number; index: number;
label: string; label: string;
icon: React.ReactNode; icon: React.ReactNode;
} }
const InputIcon = ({ d }: { d: string }) => ( interface IOOptionDef {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-7 h-7"> index: number;
<path d={d} /> icon: React.ReactNode;
</svg> }
);
const INPUT_OPTIONS: IOOption[] = [ const INPUT_OPTION_DEFS: IOOptionDef[] = [
{ {
index: 0, label: "USB-B", 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> 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", 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> 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: "同轴", 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> 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: "光纤", 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> 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: "蓝牙", 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> 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", 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> 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", 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> 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", 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> 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: "耳机", 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> 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", 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> 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({ function IOCircleButton({
option, active, onSelect, option,
}: { option: IOOption; active: boolean; onSelect: () => void }) { active,
onSelect,
}: {
option: IOOption;
active: boolean;
onSelect: () => void;
}) {
return ( return (
<button onClick={onSelect} className="flex flex-col items-center gap-2.5"> <button onClick={onSelect} className="flex flex-col items-center gap-2.5">
<div <div
className={cn( className={cn(
"w-[68px] h-[68px] rounded-full flex items-center justify-center transition-all duration-200", "w-[68px] h-[68px] rounded-full flex items-center justify-center transition-all duration-200",
active active ? "text-black" : "text-white/55 border border-white/10",
? "text-black"
: "text-white/55 border border-white/10"
)} )}
style={active ? { style={
background: "#00FFF6", active
boxShadow: "0 0 24px rgba(0,255,246,0.55), 0 0 8px rgba(0,255,246,0.3)", ? {
} : { background: "#00FFF6",
background: "rgba(44,44,46,0.9)", 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} {option.icon}
</div> </div>
@@ -105,19 +179,36 @@ export default function IOPage() {
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
const { deviceState, setInput, setOutput } = useDevice(); const { deviceState, setInput, setOutput } = useDevice();
const ioPageTitle = useMemo(() => { const sourceText = useMemo((): SourceLocale => {
const lang = deviceState?.language; const lang = deviceState?.language;
const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh; const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
return pack.source?.pageTitle ?? pack.source?.label ?? "输入输出"; return (pack.source ?? {}) as SourceLocale;
}, [deviceState?.language]); }, [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 currentInput = deviceState?.input ?? 1;
const currentOutput = deviceState?.output ?? 2; const currentOutput = deviceState?.output ?? 2;
return ( return (
<div className="min-h-screen bg-black"> <div className="min-h-screen bg-black">
<div className="page-header"> <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} /> <ChevronLeft size={24} />
</button> </button>
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">{ioPageTitle}</h1> <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 className="px-4 pb-32 pt-4 space-y-6">
<div> <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="ios-list-group p-5">
<div className="grid grid-cols-3 gap-x-2 gap-y-6"> <div className="grid grid-cols-3 gap-x-2 gap-y-6">
{INPUT_OPTIONS.map((opt) => ( {inputOptions.map((opt) => (
<IOCircleButton key={opt.index} option={opt} active={currentInput === opt.index} <IOCircleButton
onSelect={() => setInput(opt.index)} /> key={opt.index}
option={opt}
active={currentInput === opt.index}
onSelect={() => setInput(opt.index)}
/>
))} ))}
</div> </div>
</div> </div>
</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="ios-list-group p-5">
<div className="grid grid-cols-3 gap-x-2 gap-y-6"> <div className="grid grid-cols-3 gap-x-2 gap-y-6">
{OUTPUT_OPTIONS.map((opt) => ( {outputOptions.map((opt) => (
<IOCircleButton key={opt.index} option={opt} active={currentOutput === opt.index} <IOCircleButton
onSelect={() => setOutput(opt.index)} /> key={opt.index}
option={opt}
active={currentOutput === opt.index}
onSelect={() => setOutput(opt.index)}
/>
))} ))}
</div> </div>
</div> </div>