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]);