diff --git a/client/src/components/FeatureGate.tsx b/client/src/components/FeatureGate.tsx new file mode 100644 index 0000000..9c479cc --- /dev/null +++ b/client/src/components/FeatureGate.tsx @@ -0,0 +1,83 @@ +import { useState, type ReactNode, type SyntheticEvent } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { cn } from "@/lib/utils"; + +export type FeatureGateLabels = { + title: string; + description: string; + cancel: string; + confirm: string; +}; + +type FeatureGateProps = { + enabled: boolean; + labels: FeatureGateLabels; + onEnable: () => void | Promise; + className?: string; + children: ReactNode; +}; + +/** 功能关闭时拦截子区域交互,提示是否开启总开关 */ +export function FeatureGate({ + enabled, + labels, + onEnable, + className, + children, +}: FeatureGateProps) { + const [open, setOpen] = useState(false); + + const intercept = (e: SyntheticEvent) => { + if (enabled) return; + e.preventDefault(); + e.stopPropagation(); + setOpen(true); + }; + + const handleConfirm = () => { + void Promise.resolve(onEnable()).then(() => setOpen(false)); + }; + + return ( + <> +
+ {children} +
+ + + + + {labels.title} + + {labels.description} + + + + + {labels.cancel} + + + {labels.confirm} + + + + + + ); +} diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index 07a21e0..6fbed9f 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -156,7 +156,11 @@ } ] } - } + }, + "enableConfirmTitle": "Effects is off", + "enableConfirmDesc": "Effects is currently disabled. Turn it on?", + "enableConfirmOk": "Enable", + "enableConfirmCancel": "Cancel" }, "peq": { "Standard": "Standard equalizer preset", @@ -290,7 +294,10 @@ "toastApplyBSuccess": "Curve B applied to A", "toastApplyBFail": "Failed to apply B", "toastSaveBOk": "Preset saved from curve B", - "toastSaveBFail": "Failed to save curve B" + "toastSaveBFail": "Failed to save curve B", + "enableConfirmTitle": "HP-EQ is off", + "enableConfirmDesc": "HP-EQ is currently disabled. Turn it on?", + "enableConfirmOk": "Enable" } }, "dac": { diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 0f55367..701bd2e 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -92,7 +92,11 @@ { "label": "立體聲", "index": 1 } ] } - } + }, + "enableConfirmTitle": "音效未開啟", + "enableConfirmDesc": "目前 Effect 功能已關閉,是否開啟?", + "enableConfirmOk": "開啟", + "enableConfirmCancel": "取消" }, "peq": { "Standard": "標準等化器預設", @@ -226,7 +230,10 @@ "toastApplyBSuccess": "已將 B 曲線套用到 A", "toastApplyBFail": "套用 B 失敗", "toastSaveBOk": "已另存 B 預設", - "toastSaveBFail": "另存 B 失敗" + "toastSaveBFail": "另存 B 失敗", + "enableConfirmTitle": "HP-EQ 未開啟", + "enableConfirmDesc": "目前 HP-EQ 功能已關閉,是否開啟?", + "enableConfirmOk": "開啟" } }, "dac": { diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index 7c56be4..0c11587 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -92,7 +92,11 @@ { "label": "立体声", "index": 1 } ] } - } + }, + "enableConfirmTitle": "音效未开启", + "enableConfirmDesc": "当前 Effect 功能已关闭,是否开启?", + "enableConfirmOk": "开启", + "enableConfirmCancel": "取消" }, "peq": { "Standard": "标准均衡器预设", @@ -226,7 +230,10 @@ "toastApplyBSuccess": "已将 B 曲线应用到 A", "toastApplyBFail": "应用 B 失败", "toastSaveBOk": "已另存 B 预设", - "toastSaveBFail": "另存 B 失败" + "toastSaveBFail": "另存 B 失败", + "enableConfirmTitle": "HP-EQ 未开启", + "enableConfirmDesc": "当前 HP-EQ 功能已关闭,是否开启?", + "enableConfirmOk": "开启" } }, "dac": { diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index 5586c38..fead3b0 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -17,6 +17,7 @@ import { useState, useMemo, useEffect, useRef, useCallback } from "react"; import { cn } from "@/lib/utils"; import { useStrokeDrawAnimation } from "@/lib/useStrokeDrawAnimation"; import BottomNav from "@/components/BottomNav"; +import { FeatureGate } from "@/components/FeatureGate"; import { toast } from "sonner"; import { decodeCustomBase64, @@ -594,7 +595,9 @@ function bandToPeqFilter(b: { freq: number; gain: number; q: number; type: strin export default function EQPage() { const [, setLocation] = useLocation(); const { deviceState, updateSetting, api, isDemoMode, upgradePeqChange, upgradePeqApply } = useDevice(); - const eqOn = (deviceState?.peqEnable ?? 0) === 1; + const bypassOn = (deviceState?.dsp_enable ?? 0) === 0; + const peqOn = (deviceState?.peqEnable ?? 0) === 1; + const eqOn = peqOn && !bypassOn; const eqUi = useMemo((): PeqEqUi => { const lang = deviceState?.language; @@ -1361,8 +1364,6 @@ export default function EQPage() { window.clearTimeout(peqSyncTimerRef.current); } - const useApply = abMode === "B"; - peqSyncTimerRef.current = window.setTimeout(() => { const filters = filtersSource.map(bandToPeqFilter); const body: PeqPresetBody = { @@ -1372,16 +1373,13 @@ export default function EQPage() { preamp: peq.preamp ?? 0, canDel: peq.canDel ?? 1, }; - const request = useApply - ? upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload) - : upgradePeqChange({ peqChange: body } satisfies PeqChangePayload); - request.catch(() => { + upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload).catch(() => { toast.error("EQ 保存失败"); }); }, delay); }; - // A 模式编辑 → peqChange;B 模式编辑(含切换后展示)→ peqApply(防抖) + // A/B 切换与曲线编辑防抖同步 → peqApply(仅试听,不写预设) useEffect(() => { if (syncingHeadphoneRef.current) return; if (skipPeqAutoSyncRef.current) { @@ -1394,7 +1392,7 @@ export default function EQPage() { window.clearTimeout(peqSyncTimerRef.current); } }; - }, [bands, abMode, headphoneIdx, peqItems, api, isDemoMode, upgradePeqChange, upgradePeqApply]); + }, [bands, abMode, headphoneIdx, peqItems, api, isDemoMode, upgradePeqApply]); useEffect(() => { const onPointerDown = (event: PointerEvent) => { @@ -1575,10 +1573,34 @@ export default function EQPage() {

HP-EQ

- updateSetting({ peqEnable: v ? 1 : 0 })} /> + { + if (v) { + void updateSetting( + bypassOn ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 }, + ); + } else { + void updateSetting({ peqEnable: 0 }); + } + }} + /> -
+ { + const bypass = (deviceState?.dsp_enable ?? 0) === 0; + return updateSetting(bypass ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 }); + }} + > {/* ── Action cards: 批量编辑 / 添加耳机型号 ── */}
-
+ {isSaveBDialogOpen && ( diff --git a/client/src/pages/EffectsPage.tsx b/client/src/pages/EffectsPage.tsx index 234342d..c3d9f96 100644 --- a/client/src/pages/EffectsPage.tsx +++ b/client/src/pages/EffectsPage.tsx @@ -11,6 +11,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react"; import { useLocation } from "wouter"; import { useState, useEffect, useRef, useMemo } from "react"; import BottomNav from "@/components/BottomNav"; +import { FeatureGate } from "@/components/FeatureGate"; import { navigateToSelect } from "./SelectPage"; import localeZh from "@/locales/data-zh.json"; import localeZhHK from "@/locales/data-zh-HK.json"; @@ -85,7 +86,9 @@ export default function EffectsPage() { ); const ds = deviceState; - const effectOn = (ds?.audio_enable ?? 0) === 1; + const bypassOn = (ds?.dsp_enable ?? 0) === 0; + const audioOn = (ds?.audio_enable ?? 0) === 1; + const effectOn = audioOn && !bypassOn; const widthOn = (ds?.width_enable ?? 0) === 1; const widthVal = ds?.width_value ?? 50; const crossfeedOn = (ds?.crossfeed_enable ?? 0) === 1; @@ -152,10 +155,34 @@ export default function EffectsPage() {

{effectText.pageTitle}

- updateSetting({ audio_enable: v ? 1 : 0 })} /> + { + if (v) { + void updateSetting( + bypassOn ? { audio_enable: 1, dsp_enable: 1 } : { audio_enable: 1 }, + ); + } else { + void updateSetting({ audio_enable: 0 }); + } + }} + /> -
+ { + const bypass = (ds?.dsp_enable ?? 0) === 0; + return updateSetting(bypass ? { audio_enable: 1, dsp_enable: 1 } : { audio_enable: 1 }); + }} + > {/* ── Style ── */}
@@ -362,7 +389,7 @@ export default function EffectsPage() {
)}
-
+ diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 056207f..6a8eca2 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -52,6 +52,39 @@ function volToDB(v: number) { return `${db >= 0 ? "+" : ""}${db.toFixed(1)}dB`; } +/** 耳机插孔彩蛋:大孔 → 上小孔 → 下小孔 */ +const JACK_EGG_SEQUENCE = ["lg", "smTop", "smBot"] as const; +type JackId = (typeof JACK_EGG_SEQUENCE)[number]; + +const VU_BAR_COUNT = 20; +const EGG_DURATION_MS = 4200; +const EGG_BEAT_MS = 130; +const JACK_SEQUENCE_TIMEOUT_MS = 4000; + +/** 旋律主音在 20 段 VU 上的位置(约 4.2s) */ +const EGG_MELODY_LEAD = [ + 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 19, 17, 15, 13, 11, 9, 7, 5, 3, 1, + 0, 4, 8, 12, 16, 19, 16, 12, 8, 4, 0, 2, 5, 9, 13, 17, 19, 14, 10, 6, 2, +]; + +function idleVuBarHeights(): number[] { + return Array.from({ length: VU_BAR_COUNT }, (_, i) => + Math.max(0.12, Math.sin((i / VU_BAR_COUNT) * Math.PI) * 0.65 + 0.2), + ); +} + +function vuHeightsForMelodyBeat(lead: number, pulse: number): number[] { + return Array.from({ length: VU_BAR_COUNT }, (_, i) => { + const dist = Math.abs(i - lead); + let amp = 0.14; + if (dist === 0) amp = 0.96; + else if (dist === 1) amp = 0.74; + else if (dist === 2) amp = 0.5; + else if (dist === 3) amp = 0.3; + return Math.min(1, amp * (0.82 + pulse * 0.18)); + }); +} + // ── iOS-style Toggle ── function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( @@ -106,6 +139,11 @@ export default function Home() { const effectLastTapRef = useRef(null); const effectNavigateTimerRef = useRef | null>(null); const [powerConfirmOpen, setPowerConfirmOpen] = useState(false); + const [easterEggActive, setEasterEggActive] = useState(false); + const [vuHeights, setVuHeights] = useState(idleVuBarHeights); + const jackProgressRef = useRef(0); + const jackResetTimerRef = useRef | null>(null); + const eggRafRef = useRef(null); const homeText = useMemo((): HomeLocale => { if (!deviceState) return {} as HomeLocale; @@ -174,10 +212,15 @@ export default function Home() { hpEqNavigateTimerRef.current = setTimeout(() => { hpEqNavigateTimerRef.current = null; hpEqLastTapRef.current = null; - const on = (deviceState?.peqEnable ?? 0) === 1; - void updateSetting({ peqEnable: on ? 0 : 1 }); + const bypassOn = (deviceState?.dsp_enable ?? 0) === 0; + if (bypassOn) { + void updateSetting({ peqEnable: 1, dsp_enable: 1 }); + } else { + const on = (deviceState?.peqEnable ?? 0) === 1; + void updateSetting({ peqEnable: on ? 0 : 1 }); + } }, HP_EQ_DOUBLE_CLICK_MS); - }, [deviceState?.peqEnable, updateSetting, setLocation]); + }, [deviceState?.peqEnable, deviceState?.dsp_enable, updateSetting, setLocation]); /** Effect 圆钮:单击短延迟后切换 audio_enable;280ms 内第二次点击则进入 /effects */ const handleEffectCircleClick = useCallback(() => { @@ -197,10 +240,15 @@ export default function Home() { effectNavigateTimerRef.current = setTimeout(() => { effectNavigateTimerRef.current = null; effectLastTapRef.current = null; - const on = (deviceState?.audio_enable ?? 0) === 1; - void updateSetting({ audio_enable: on ? 0 : 1 }); + const bypassOn = (deviceState?.dsp_enable ?? 0) === 0; + if (bypassOn) { + void updateSetting({ audio_enable: 1, dsp_enable: 1 }); + } else { + const on = (deviceState?.audio_enable ?? 0) === 1; + void updateSetting({ audio_enable: on ? 0 : 1 }); + } }, HP_EQ_DOUBLE_CLICK_MS); - }, [deviceState?.audio_enable, updateSetting, setLocation]); + }, [deviceState?.audio_enable, deviceState?.dsp_enable, updateSetting, setLocation]); useEffect(() => { return () => { @@ -209,6 +257,58 @@ export default function Home() { }; }, []); + const triggerJackEasterEgg = useCallback(() => { + setEasterEggActive(true); + jackProgressRef.current = 0; + const start = performance.now(); + + const tick = (now: number) => { + const elapsed = now - start; + if (elapsed >= EGG_DURATION_MS) { + setEasterEggActive(false); + setVuHeights(idleVuBarHeights()); + eggRafRef.current = null; + return; + } + const beat = Math.floor(elapsed / EGG_BEAT_MS) % EGG_MELODY_LEAD.length; + const pulse = 0.5 + 0.5 * Math.sin(elapsed * 0.02); + setVuHeights(vuHeightsForMelodyBeat(EGG_MELODY_LEAD[beat], pulse)); + eggRafRef.current = requestAnimationFrame(tick); + }; + + if (eggRafRef.current !== null) cancelAnimationFrame(eggRafRef.current); + eggRafRef.current = requestAnimationFrame(tick); + }, []); + + const onJackClick = useCallback( + (jack: JackId) => { + if (easterEggActive) return; + + const expected = JACK_EGG_SEQUENCE[jackProgressRef.current]; + if (jack === expected) { + jackProgressRef.current += 1; + if (jackProgressRef.current >= JACK_EGG_SEQUENCE.length) { + triggerJackEasterEgg(); + } + } else { + jackProgressRef.current = jack === JACK_EGG_SEQUENCE[0] ? 1 : 0; + } + + if (jackResetTimerRef.current) clearTimeout(jackResetTimerRef.current); + jackResetTimerRef.current = setTimeout(() => { + jackProgressRef.current = 0; + }, JACK_SEQUENCE_TIMEOUT_MS); + }, + [easterEggActive, triggerJackEasterEgg], + ); + + useEffect(() => { + return () => { + if (jackResetTimerRef.current) clearTimeout(jackResetTimerRef.current); + if (eggRafRef.current !== null) cancelAnimationFrame(eggRafRef.current); + }; + }, []); + if (!isConnected) return ; const ds = deviceState!; @@ -305,34 +405,43 @@ export default function Home() { {/* Left ports */}
- {/* Big hole (left) */} -
onJackClick("lg")} >
-
+ {/* Two small holes (right, stacked) */}
-
onJackClick("smTop")} /> -
onJackClick("smBot")} />
@@ -345,17 +454,27 @@ export default function Home() { {volToDB(localVol)}
{/* Mini VU bars */} -
- {Array.from({ length: 20 }).map((_, i) => { - const h = Math.max(0.1, Math.sin((i / 20) * Math.PI) * 0.8 + Math.random() * 0.2); +
+ {vuHeights.map((h, i) => { const isHot = i > 16; return ( -
13 ? "#f59e0b" : "#00FFF6", - opacity: 0.7, - }} /> + opacity: easterEggActive ? 0.95 : 0.7, + transition: easterEggActive + ? "height 90ms ease-out, opacity 120ms ease-out" + : "height 200ms ease-out", + }} + /> ); })}