diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json
index 2ce8763..951f69a 100644
--- a/client/src/locales/data-en.json
+++ b/client/src/locales/data-en.json
@@ -36,6 +36,9 @@
}
},
"effect": {
+ "pageTitle": "Effects",
+ "selectStyleTitle": "Select sound style",
+ "selectCrossfeedTitle": "Select crossfeed preset",
"lowBass": "Bass",
"enterBass": "Mid",
"highBass": "Treble",
@@ -47,7 +50,7 @@
"mainframeCase": "Main speaker",
"subwooferUnit": "Subwoofer",
"style": {
- "label": "style",
+ "label": "Style",
"options": [
{
"index": 0,
@@ -119,7 +122,12 @@
"label": "Stereo width"
},
"crossfeed": {
- "label": "Crossfeed"
+ "label": "Crossfeed",
+ "options": [
+ "BS2B default (700 Hz, 4.5 dB)",
+ "BS2B popular (700 Hz, 6 dB)",
+ "BS2B relax (650 Hz, 9.5 dB)"
+ ]
},
"subwoofer": {
"label": "Subwoofer",
@@ -145,7 +153,7 @@
"Standard": "Standard equalizer preset",
"edit": "Batch Edit",
"headset": "Add HeadSet",
- "preamp": "Total gain",
+ "preamp": "Preamp",
"filters": {
"label": "Filters",
"options": [
diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json
index a531757..7508241 100644
--- a/client/src/locales/data-zh-HK.json
+++ b/client/src/locales/data-zh-HK.json
@@ -29,6 +29,9 @@
"output": { "headset": "耳機" }
},
"effect": {
+ "pageTitle": "音效",
+ "selectStyleTitle": "選擇音效風格",
+ "selectCrossfeedTitle": "選擇交叉回授模式",
"lowBass": "低頻",
"enterBass": "中頻",
"highBass": "高頻",
@@ -60,7 +63,14 @@
]
},
"stereoWidth": { "label": "聲場寬度" },
- "crossfeed": { "label": "交叉回授" },
+ "crossfeed": {
+ "label": "交叉回授",
+ "options": [
+ "BS2B default(700Hz,4.5dB)",
+ "BS2B polular(700Hz,6dB)",
+ "BS2B relax(650Hz,9.5dB)"
+ ]
+ },
"subwoofer": {
"label": "重低音輸出",
"freq": "低通頻率",
diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json
index 17ae7be..db42ea4 100644
--- a/client/src/locales/data-zh.json
+++ b/client/src/locales/data-zh.json
@@ -29,6 +29,9 @@
"output": { "headset": "耳机" }
},
"effect": {
+ "pageTitle": "音效",
+ "selectStyleTitle": "选择音效风格",
+ "selectCrossfeedTitle": "选择交叉反馈模式",
"lowBass": "低音",
"enterBass": "中音",
"highBass": "高音",
@@ -39,7 +42,7 @@
"mainframeCase": "主机箱",
"subwooferUnit": "低音炮",
"style": {
- "label": "Style",
+ "label": "风格",
"options": [
{ "index": 0, "label": "古典" },
{ "index": 1, "label": "舞曲" },
@@ -60,7 +63,14 @@
]
},
"stereoWidth": { "label": "声场宽度" },
- "crossfeed": { "label": "交叉反馈" },
+ "crossfeed": {
+ "label": "交叉反馈",
+ "options": [
+ "BS2B default(700Hz,4.5dB)",
+ "BS2B polular(700Hz,6dB)",
+ "BS2B relax(650Hz,9.5dB)"
+ ]
+ },
"subwoofer": {
"label": "低音炮输出",
"freq": "低通频率",
diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx
index 6b19c6a..f398ee8 100644
--- a/client/src/pages/EQPage.tsx
+++ b/client/src/pages/EQPage.tsx
@@ -1225,6 +1225,15 @@ export default function EQPage() {
loadHeadphones();
}, [api, isDemoMode, loadRawCurveForPeq]);
+ /** 仅耳机/型号/filters 变化时变;preamp、autoPre 变化不触发,避免拖总增益时反复请求 modelCurve */
+ const modelCurveReloadKey = useMemo(() => {
+ const peq = peqItems[headphoneIdx];
+ if (!peq) return "";
+ const filtersKey =
+ typeof peq.filters === "string" ? peq.filters : JSON.stringify(peq.filters ?? []);
+ return [headphoneIdx, peq.name ?? "", peq.brand ?? "", peq.model ?? "", filtersKey].join("\u0001");
+ }, [headphoneIdx, peqItems]);
+
// 切换耳机型号后,立即用该型号 filters 刷新 10 个滤波器与曲线(暂停一次上报避免错写)
useEffect(() => {
const peq = peqItems[headphoneIdx];
@@ -1247,7 +1256,7 @@ export default function EQPage() {
return () => {
cancelled = true;
};
- }, [headphoneIdx, peqItems, loadRawCurveForPeq]);
+ }, [modelCurveReloadKey, loadRawCurveForPeq]);
const schedulePeqSync = (
peq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined,
diff --git a/client/src/pages/EffectsPage.tsx b/client/src/pages/EffectsPage.tsx
index 42e0281..234342d 100644
--- a/client/src/pages/EffectsPage.tsx
+++ b/client/src/pages/EffectsPage.tsx
@@ -9,9 +9,19 @@
import { useDevice } from "@/contexts/DeviceContext";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useLocation } from "wouter";
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect, useRef, useMemo } from "react";
import BottomNav from "@/components/BottomNav";
import { navigateToSelect } from "./SelectPage";
+import localeZh from "@/locales/data-zh.json";
+import localeZhHK from "@/locales/data-zh-HK.json";
+import localeEn from "@/locales/data-en.json";
+
+type EffectLocale = NonNullable<(typeof localeZh)["effect"]>;
+
+function clampPickIndex(i: number, len: number) {
+ if (len <= 0) return 0;
+ return Math.min(Math.max(i, 0), len - 1);
+}
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
@@ -22,10 +32,20 @@ function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: bool
);
}
-function CyanSlider({ value, min, max, step = 0.1, onChange, onMouseUp }: {
- value: number; min: number; max: number; step?: number; onChange: (v: number) => void; onMouseUp?: () => void;
+function CyanSlider({ value, min, max, step = 0.1, onChange, onRelease }: {
+ value: number;
+ min: number;
+ max: number;
+ step?: number;
+ onChange: (v: number) => void;
+ /** Fires once when the user releases the thumb (pointer/mouse/touch). Read value from DOM to avoid stale React state. */
+ onRelease?: (v: number) => void;
}) {
const fillPct = ((value - min) / (max - min)) * 100;
+ const emitRelease = (el: EventTarget | null) => {
+ if (!(el instanceof HTMLInputElement)) return;
+ onRelease?.(Number(el.value));
+ };
return (
onChange(Number(e.target.value))}
- onMouseUp={onMouseUp}
- onTouchEnd={onMouseUp} />
+ onPointerUp={(e) => emitRelease(e.currentTarget)}
+ onPointerCancel={(e) => emitRelease(e.currentTarget)}
+ onKeyUp={(e) => {
+ if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(e.key)) {
+ emitRelease(e.currentTarget);
+ }
+ }} />
);
}
-const SCENE_OPTIONS = [
- "古典", "舞曲", "流行", "雷鬼", "现场", "摇滚", "柔和", "电子乐",
- "俱乐部", "全低音", "全高音", "耳机", "大厅", "聚合", "斯卡", "慢摇"
-];
-const CROSSFEED_OPTIONS = [
- "BS2B default(700Hz,4.5dB)",
- "BS2B polular(700Hz,6dB)",
- "BS2B relax(650Hz,9.5dB)",
-];
-
export default function EffectsPage() {
const [, setLocation] = useLocation();
const { deviceState, updateSetting } = useDevice();
+ const effectText = useMemo((): EffectLocale => {
+ const lang = deviceState?.language;
+ const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
+ return (pack.effect ?? localeZh.effect) as EffectLocale;
+ }, [deviceState?.language]);
+
+ const sceneLabels = useMemo(() => {
+ const opts = effectText.style?.options ?? [];
+ return [...opts].sort((a, b) => a.index - b.index).map((o) => o.label);
+ }, [effectText]);
+
+ const crossfeedLabels = useMemo(
+ () => effectText.crossfeed?.options ?? [],
+ [effectText],
+ );
+
const ds = deviceState;
const effectOn = (ds?.audio_enable ?? 0) === 1;
const widthOn = (ds?.width_enable ?? 0) === 1;
@@ -93,11 +124,11 @@ export default function EffectsPage() {
if (!isDraggingLoudnessTreble.current) setLocalLoudnessTreble(loudnessTrebleGain);
}, [colorBassGain, colorMidGain, colorTrebleGain, loudnessThreshold, loudnessBassGain, loudnessTrebleGain]);
- const [localWidth, setLocalWidth] = useState(widthVal);
+ const [localWidth, setLocalWidth] = useState(() => Math.round(widthVal));
const isDragging = useRef(false);
useEffect(() => {
- if (!isDragging.current) setLocalWidth(widthVal);
+ if (!isDragging.current) setLocalWidth(Math.round(widthVal));
}, [widthVal]);
const goSelect = (title: string, options: string[], selected: number, key: string) => {
@@ -105,6 +136,11 @@ export default function EffectsPage() {
setLocation("/select");
};
+ const sceneIdx = clampPickIndex(sceneVal, sceneLabels.length);
+ const sceneDisplay = sceneLabels[sceneIdx] ?? "";
+ const crossIdx = clampPickIndex(crossfeedVal, crossfeedLabels.length);
+ const crossfeedDisplay = crossfeedLabels[crossIdx] ?? "";
+
return (
setLocation("/")} className="mr-4 text-white/60 active:text-white transition-colors">
-
Effect
+ {effectText.pageTitle}
updateSetting({ audio_enable: v ? 1 : 0 })} />
@@ -123,13 +159,13 @@ export default function EffectsPage() {
{/* ── Style ── */}
- Style
+ {effectText.style.label}
updateSetting({ effect_enable: v ? 1 : 0 })} />
@@ -138,30 +174,38 @@ export default function EffectsPage() {
- 声场宽度
- {localWidth}
+ {effectText.stereoWidth.label}
+ {Math.round(localWidth)}
updateSetting({ width_enable: v ? 1 : 0 })} />
-
{
isDragging.current = true;
- setLocalWidth(v);
- updateSetting({ width_value: v });
- setTimeout(() => { isDragging.current = false; }, 500);
+ setLocalWidth(Math.round(v));
+ }}
+ onRelease={(v) => {
+ isDragging.current = false;
+ const w = Math.round(v);
+ setLocalWidth(w);
+ updateSetting({ width_value: w });
}} />
{/* ── 交叉反馈 ── */}
- 交叉反馈
+ {effectText.crossfeed.label}
updateSetting({ crossfeed_enable: v ? 1 : 0 })} />
@@ -169,7 +213,7 @@ export default function EffectsPage() {
{/* ── 音调 ── */}
- 音调
+ {effectText.tone}
updateSetting({ color_enable: v ? 1 : 0 })} />
@@ -178,20 +222,20 @@ export default function EffectsPage() {
{/* 低音 */}
- 低音
+ {effectText.lowBass}
{localBass.toFixed(1)}
-
{
isDraggingBass.current = true;
setLocalBass(v);
}}
- onMouseUp={() => {
+ onRelease={(v) => {
isDraggingBass.current = false;
- updateSetting({ color_bass_gain: Math.round(localBass * 10) });
+ updateSetting({ color_bass_gain: Math.round(v * 10) });
}}
/>
@@ -199,20 +243,20 @@ export default function EffectsPage() {
{/* 中音 */}
- 中音
+ {effectText.enterBass}
{localMid.toFixed(1)}
-
{
isDraggingMid.current = true;
setLocalMid(v);
}}
- onMouseUp={() => {
+ onRelease={(v) => {
isDraggingMid.current = false;
- updateSetting({ color_mid_gain: Math.round(localMid * 10) });
+ updateSetting({ color_mid_gain: Math.round(v * 10) });
}}
/>
@@ -220,20 +264,20 @@ export default function EffectsPage() {
{/* 高音 */}
- 高音
+ {effectText.highBass}
{localTreble.toFixed(1)}
-
{
isDraggingTreble.current = true;
setLocalTreble(v);
}}
- onMouseUp={() => {
+ onRelease={(v) => {
isDraggingTreble.current = false;
- updateSetting({ color_treble_gain: Math.round(localTreble * 10) });
+ updateSetting({ color_treble_gain: Math.round(v * 10) });
}}
/>
@@ -244,7 +288,7 @@ export default function EffectsPage() {
{/* ── 响度 ── */}
- 响度
+ {effectText.loudness}
updateSetting({ loudness_enable: v ? 1 : 0 })} />
@@ -253,65 +297,65 @@ export default function EffectsPage() {
{/* 阈值 */}
- 阈值
+ {effectText.threshold}
{localLoudnessThreshold}
-
{
isDraggingLoudnessThreshold.current = true;
setLocalLoudnessThreshold(v);
}}
- onMouseUp={() => {
+ onRelease={(v) => {
isDraggingLoudnessThreshold.current = false;
- updateSetting({ loudness_threshold_gain: Math.round(localLoudnessThreshold) });
+ updateSetting({ loudness_threshold_gain: Math.round(v) });
}}
/>
- {/* 低音 */}
+ {/* Loudness bass */}
- 低音
+ {effectText.lowBass}
{localLoudnessBass.toFixed(1)}
-
{
isDraggingLoudnessBass.current = true;
setLocalLoudnessBass(v);
}}
- onMouseUp={() => {
+ onRelease={(v) => {
isDraggingLoudnessBass.current = false;
- updateSetting({ loudness_bass_gain: Math.round(localLoudnessBass * 10) });
+ updateSetting({ loudness_bass_gain: Math.round(v * 10) });
}}
/>
- {/* 高音 */}
+ {/* Loudness treble */}
- 高音
+ {effectText.highBass}
{localLoudnessTreble.toFixed(1)}
-
{
isDraggingLoudnessTreble.current = true;
setLocalLoudnessTreble(v);
}}
- onMouseUp={() => {
+ onRelease={(v) => {
isDraggingLoudnessTreble.current = false;
- updateSetting({ loudness_treble_gain: Math.round(localLoudnessTreble * 10) });
+ updateSetting({ loudness_treble_gain: Math.round(v * 10) });
}}
/>
diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx
index ea2fd14..d1d8e40 100644
--- a/client/src/pages/Home.tsx
+++ b/client/src/pages/Home.tsx
@@ -155,7 +155,7 @@ export default function Home() {
}
}, [api, disconnect, isDemoMode, homeText]);
- /** HP-EQ 圆钮:单击进入 /eq;双击在 280ms 内第二次点击则切换 peqEnable(与 info.cgi?action=setting&peqEnable=0|1 一致) */
+ /** HP-EQ 圆钮:单击在短延迟后切换 peqEnable(避免与双击抢);280ms 内第二次点击则取消开关并进入 /eq */
const HP_EQ_DOUBLE_CLICK_MS = 280;
const handleHpEqCircleClick = useCallback(() => {
const now = Date.now();
@@ -166,8 +166,7 @@ export default function Home() {
clearTimeout(hpEqNavigateTimerRef.current);
hpEqNavigateTimerRef.current = null;
}
- const on = (deviceState?.peqEnable ?? 0) === 1;
- void updateSetting({ peqEnable: on ? 0 : 1 });
+ setLocation("/eq");
return;
}
hpEqLastTapRef.current = now;
@@ -175,11 +174,12 @@ export default function Home() {
hpEqNavigateTimerRef.current = setTimeout(() => {
hpEqNavigateTimerRef.current = null;
hpEqLastTapRef.current = null;
- setLocation("/eq");
+ const on = (deviceState?.peqEnable ?? 0) === 1;
+ void updateSetting({ peqEnable: on ? 0 : 1 });
}, HP_EQ_DOUBLE_CLICK_MS);
}, [deviceState?.peqEnable, updateSetting, setLocation]);
- /** Effect 圆钮:单击进入 /effects;双击切换 audio_enable */
+ /** Effect 圆钮:单击短延迟后切换 audio_enable;280ms 内第二次点击则进入 /effects */
const handleEffectCircleClick = useCallback(() => {
const now = Date.now();
const last = effectLastTapRef.current;
@@ -189,8 +189,7 @@ export default function Home() {
clearTimeout(effectNavigateTimerRef.current);
effectNavigateTimerRef.current = null;
}
- const on = (deviceState?.audio_enable ?? 0) === 1;
- void updateSetting({ audio_enable: on ? 0 : 1 });
+ setLocation("/effects");
return;
}
effectLastTapRef.current = now;
@@ -198,7 +197,8 @@ export default function Home() {
effectNavigateTimerRef.current = setTimeout(() => {
effectNavigateTimerRef.current = null;
effectLastTapRef.current = null;
- setLocation("/effects");
+ const on = (deviceState?.audio_enable ?? 0) === 1;
+ void updateSetting({ audio_enable: on ? 0 : 1 });
}, HP_EQ_DOUBLE_CLICK_MS);
}, [deviceState?.audio_enable, updateSetting, setLocation]);