diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index c12bd38..4b057cb 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -18,6 +18,12 @@ "powerOffConfirmDesc": "The device will shut down and disconnect from this app. Do you want to continue?", "powerOffConfirmOk": "Turn off", "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.", "powerOffToastSent": "Shutdown command sent.", "doubleClickHint": "Double-click to enter", @@ -43,6 +49,10 @@ "source": { "label": "Input/Output", "pageTitle": "I/O", + "inputSection": "Input", + "outputSection": "Output", + "inputOptions": ["USB-B", "USB-C", "Coaxial", "Optical", "Bluetooth", "IIS"], + "outputOptions": ["XLR", "RCA", "Headset", "XLR/RCA"], "input": { "coaxial": "COAXIAL", "optical": "OPTICAL", @@ -50,7 +60,7 @@ "rca": "RCA" }, "output": { - "headset": "HeadSet" + "headset": "Headset" } }, "effect": { @@ -306,7 +316,17 @@ "toastSaveBFail": "Failed to save curve B", "enableConfirmTitle": "HP-EQ is off", "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": { diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 9990bde..5aad87f 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -18,6 +18,12 @@ "powerOffConfirmDesc": "裝置將關機並中斷與本應用程式的連線,是否繼續?", "powerOffConfirmOk": "關閉電源", "powerOffConfirmCancel": "取消", + "volumeMinConfirmTitle": "確認將音量設為最小?", + "volumeMinConfirmDesc": "耳機音量將調至最低(靜音),是否繼續?", + "volumeMinConfirmOk": "設為最小", + "volumeMaxConfirmTitle": "確認將音量設為最大?", + "volumeMaxConfirmDesc": "耳機音量將調至最大,是否繼續?", + "volumeMaxConfirmOk": "設為最大", "powerOffToastDemo": "演示模式:已中斷與裝置的連線", "powerOffToastSent": "已傳送關機指令", "doubleClickHint": "按兩下進入", @@ -43,6 +49,10 @@ "source": { "label": "輸入/輸出", "pageTitle": "輸入/輸出", + "inputSection": "輸入", + "outputSection": "輸出", + "inputOptions": ["USB-B", "USB-C", "同軸", "光纖", "藍牙", "IIS"], + "outputOptions": ["XLR", "RCA", "耳機", "XLR/RCA"], "input": { "coaxial": "同軸", "optical": "光纖", "bluetooth": "藍牙", "rca": "類比RCA" }, "output": { "headset": "耳機" } }, @@ -242,7 +252,17 @@ "toastSaveBFail": "另存 B 失敗", "enableConfirmTitle": "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": { diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index b743b77..4683976 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -18,6 +18,12 @@ "powerOffConfirmDesc": "设备将关机并断开与本应用的连接,是否继续?", "powerOffConfirmOk": "关闭电源", "powerOffConfirmCancel": "取消", + "volumeMinConfirmTitle": "确认将音量设为最小?", + "volumeMinConfirmDesc": "耳机音量将调至最低(静音),是否继续?", + "volumeMinConfirmOk": "设为最小", + "volumeMaxConfirmTitle": "确认将音量设为最大?", + "volumeMaxConfirmDesc": "耳机音量将调至最大,是否继续?", + "volumeMaxConfirmOk": "设为最大", "powerOffToastDemo": "演示模式:已断开与设备的连接", "powerOffToastSent": "已发送关机指令", "doubleClickHint": "双击进入", @@ -43,7 +49,11 @@ "source": { "label": "输入输出", "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": "耳机" } }, "effect": { @@ -242,7 +252,17 @@ "toastSaveBFail": "另存 B 失败", "enableConfirmTitle": "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": { diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index fead3b0..95e9850 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -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(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() {
FREQ - - {band.freq >= 1000 ? `${(band.freq / 1000).toFixed(2).replace(/\.?0+$/, "")} kHz` : `${band.freq} Hz`} - +
- updateBand(selectedBand, { freq: Math.round(Math.pow(10, v)) })} /> + updateBand(selectedBand, { freq: Math.round(Math.pow(10, v)) })} + />
{/* GAIN */}
GAIN - +
- updateBand(selectedBand, { gain: v })} /> + updateBand(selectedBand, { gain: v })} + />
{/* Q 值 */}
{peqCardLabels.q ?? "Q值"} - +
- updateBand(selectedBand, { q: v })} /> + updateBand(selectedBand, { q: v })} + />
@@ -1865,6 +2040,66 @@ export default function EQPage() { + {bandParamDialog && bandParamDialogMeta && ( +
+
e.stopPropagation()} + > +
+

{bandParamDialogMeta.title}

+ +
+

{bandParamDialogMeta.hint}

+ 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 + /> +
+ + +
+
+
+ )} + {isSaveBDialogOpen && (
(null); const effectNavigateTimerRef = useRef | null>(null); const [powerConfirmOpen, setPowerConfirmOpen] = useState(false); + const [volumeConfirm, setVolumeConfirm] = useState(null); const [easterEggActive, setEasterEggActive] = useState(false); const [vuHeights, setVuHeights] = useState(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 ── */}
-
-
+ { + if (!open) setVolumeConfirm(null); + }} + > + + + + {volumeConfirmCopy?.title} + + + {volumeConfirmCopy?.desc} + + + + + {homeText.powerOffConfirmCancel ?? "取消"} + + { + if (volumeConfirm === "min") { + setLocalVol(0); + setVolume(0); + } else if (volumeConfirm === "max") { + setLocalVol(200); + setVolume(200); + } + setVolumeConfirm(null); + }} + > + {volumeConfirmCopy?.ok} + + + + + - + {homeText.powerOffConfirmTitle ?? "确认关闭电源?"} diff --git a/client/src/pages/IOPage.tsx b/client/src/pages/IOPage.tsx index 36cb8d6..0f87638 100644 --- a/client/src/pages/IOPage.tsx +++ b/client/src/pages/IOPage.tsx @@ -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 }) => ( - - - -); +interface IOOptionDef { + index: number; + icon: React.ReactNode; +} -const INPUT_OPTIONS: IOOption[] = [ +const INPUT_OPTION_DEFS: IOOptionDef[] = [ { - index: 0, label: "USB-B", - icon: + index: 0, + icon: ( + + + + + + ), }, { - index: 1, label: "USB-C", - icon: + index: 1, + icon: ( + + + + ), }, { - index: 2, label: "同轴", - icon: + index: 2, + icon: ( + + + + + ), }, { - index: 3, label: "光纤", - icon: + index: 3, + icon: ( + + + + + + ), }, { - index: 4, label: "蓝牙", - icon: + index: 4, + icon: ( + + + + ), }, { - index: 5, label: "IIS", - icon: + index: 5, + icon: ( + + + + + + + ), }, ]; -const OUTPUT_OPTIONS: IOOption[] = [ +const OUTPUT_OPTION_DEFS: IOOptionDef[] = [ { - index: 0, label: "XLR", - icon: + index: 0, + icon: ( + + + + + + + ), }, { - index: 1, label: "RCA", - icon: + index: 1, + icon: ( + + + + + ), }, { - index: 2, label: "耳机", - icon: + index: 2, + icon: ( + + + + + + ), }, { - index: 3, label: "XLR/RCA", - icon: + index: 3, + icon: ( + + + + + + + ), }, ]; +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 (

{ioPageTitle}

@@ -126,24 +217,32 @@ export default function IOPage() {
-

Input

+

{inputSectionTitle}

- {INPUT_OPTIONS.map((opt) => ( - setInput(opt.index)} /> + {inputOptions.map((opt) => ( + setInput(opt.index)} + /> ))}
-

Output

+

{outputSectionTitle}

- {OUTPUT_OPTIONS.map((opt) => ( - setOutput(opt.index)} /> + {outputOptions.map((opt) => ( + setOutput(opt.index)} + /> ))}