Update locale files for English, Traditional Chinese, and Simplified Chinese to include new confirmation prompts for enabling audio effects; refactor EffectsPage and EQPage to integrate FeatureGate for managing effect states and user interactions.
This commit is contained in:
@@ -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<void>;
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(className)}
|
||||||
|
onPointerDownCapture={intercept}
|
||||||
|
onClickCapture={intercept}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogContent className="border-white/10 bg-zinc-900 text-white sm:max-w-md">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-white">{labels.title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-white/60">
|
||||||
|
{labels.description}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="border-white/20 bg-transparent text-white hover:bg-white/10">
|
||||||
|
{labels.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-[#00FFF6] text-black hover:brightness-95 focus-visible:ring-[#00FFF6]"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
{labels.confirm}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -156,7 +156,11 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"enableConfirmTitle": "Effects is off",
|
||||||
|
"enableConfirmDesc": "Effects is currently disabled. Turn it on?",
|
||||||
|
"enableConfirmOk": "Enable",
|
||||||
|
"enableConfirmCancel": "Cancel"
|
||||||
},
|
},
|
||||||
"peq": {
|
"peq": {
|
||||||
"Standard": "Standard equalizer preset",
|
"Standard": "Standard equalizer preset",
|
||||||
@@ -290,7 +294,10 @@
|
|||||||
"toastApplyBSuccess": "Curve B applied to A",
|
"toastApplyBSuccess": "Curve B applied to A",
|
||||||
"toastApplyBFail": "Failed to apply B",
|
"toastApplyBFail": "Failed to apply B",
|
||||||
"toastSaveBOk": "Preset saved from curve 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": {
|
"dac": {
|
||||||
|
|||||||
@@ -92,7 +92,11 @@
|
|||||||
{ "label": "立體聲", "index": 1 }
|
{ "label": "立體聲", "index": 1 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"enableConfirmTitle": "音效未開啟",
|
||||||
|
"enableConfirmDesc": "目前 Effect 功能已關閉,是否開啟?",
|
||||||
|
"enableConfirmOk": "開啟",
|
||||||
|
"enableConfirmCancel": "取消"
|
||||||
},
|
},
|
||||||
"peq": {
|
"peq": {
|
||||||
"Standard": "標準等化器預設",
|
"Standard": "標準等化器預設",
|
||||||
@@ -226,7 +230,10 @@
|
|||||||
"toastApplyBSuccess": "已將 B 曲線套用到 A",
|
"toastApplyBSuccess": "已將 B 曲線套用到 A",
|
||||||
"toastApplyBFail": "套用 B 失敗",
|
"toastApplyBFail": "套用 B 失敗",
|
||||||
"toastSaveBOk": "已另存 B 預設",
|
"toastSaveBOk": "已另存 B 預設",
|
||||||
"toastSaveBFail": "另存 B 失敗"
|
"toastSaveBFail": "另存 B 失敗",
|
||||||
|
"enableConfirmTitle": "HP-EQ 未開啟",
|
||||||
|
"enableConfirmDesc": "目前 HP-EQ 功能已關閉,是否開啟?",
|
||||||
|
"enableConfirmOk": "開啟"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dac": {
|
"dac": {
|
||||||
|
|||||||
@@ -92,7 +92,11 @@
|
|||||||
{ "label": "立体声", "index": 1 }
|
{ "label": "立体声", "index": 1 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"enableConfirmTitle": "音效未开启",
|
||||||
|
"enableConfirmDesc": "当前 Effect 功能已关闭,是否开启?",
|
||||||
|
"enableConfirmOk": "开启",
|
||||||
|
"enableConfirmCancel": "取消"
|
||||||
},
|
},
|
||||||
"peq": {
|
"peq": {
|
||||||
"Standard": "标准均衡器预设",
|
"Standard": "标准均衡器预设",
|
||||||
@@ -226,7 +230,10 @@
|
|||||||
"toastApplyBSuccess": "已将 B 曲线应用到 A",
|
"toastApplyBSuccess": "已将 B 曲线应用到 A",
|
||||||
"toastApplyBFail": "应用 B 失败",
|
"toastApplyBFail": "应用 B 失败",
|
||||||
"toastSaveBOk": "已另存 B 预设",
|
"toastSaveBOk": "已另存 B 预设",
|
||||||
"toastSaveBFail": "另存 B 失败"
|
"toastSaveBFail": "另存 B 失败",
|
||||||
|
"enableConfirmTitle": "HP-EQ 未开启",
|
||||||
|
"enableConfirmDesc": "当前 HP-EQ 功能已关闭,是否开启?",
|
||||||
|
"enableConfirmOk": "开启"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dac": {
|
"dac": {
|
||||||
|
|||||||
+34
-12
@@ -17,6 +17,7 @@ import { useState, useMemo, useEffect, useRef, useCallback } from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useStrokeDrawAnimation } from "@/lib/useStrokeDrawAnimation";
|
import { useStrokeDrawAnimation } from "@/lib/useStrokeDrawAnimation";
|
||||||
import BottomNav from "@/components/BottomNav";
|
import BottomNav from "@/components/BottomNav";
|
||||||
|
import { FeatureGate } from "@/components/FeatureGate";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
decodeCustomBase64,
|
decodeCustomBase64,
|
||||||
@@ -594,7 +595,9 @@ function bandToPeqFilter(b: { freq: number; gain: number; q: number; type: strin
|
|||||||
export default function EQPage() {
|
export default function EQPage() {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const { deviceState, updateSetting, api, isDemoMode, upgradePeqChange, upgradePeqApply } = useDevice();
|
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 eqUi = useMemo((): PeqEqUi => {
|
||||||
const lang = deviceState?.language;
|
const lang = deviceState?.language;
|
||||||
@@ -1361,8 +1364,6 @@ export default function EQPage() {
|
|||||||
window.clearTimeout(peqSyncTimerRef.current);
|
window.clearTimeout(peqSyncTimerRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
const useApply = abMode === "B";
|
|
||||||
|
|
||||||
peqSyncTimerRef.current = window.setTimeout(() => {
|
peqSyncTimerRef.current = window.setTimeout(() => {
|
||||||
const filters = filtersSource.map(bandToPeqFilter);
|
const filters = filtersSource.map(bandToPeqFilter);
|
||||||
const body: PeqPresetBody = {
|
const body: PeqPresetBody = {
|
||||||
@@ -1372,16 +1373,13 @@ export default function EQPage() {
|
|||||||
preamp: peq.preamp ?? 0,
|
preamp: peq.preamp ?? 0,
|
||||||
canDel: peq.canDel ?? 1,
|
canDel: peq.canDel ?? 1,
|
||||||
};
|
};
|
||||||
const request = useApply
|
upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload).catch(() => {
|
||||||
? upgradePeqApply({ peqApply: body } satisfies PeqApplyPayload)
|
|
||||||
: upgradePeqChange({ peqChange: body } satisfies PeqChangePayload);
|
|
||||||
request.catch(() => {
|
|
||||||
toast.error("EQ 保存失败");
|
toast.error("EQ 保存失败");
|
||||||
});
|
});
|
||||||
}, delay);
|
}, delay);
|
||||||
};
|
};
|
||||||
|
|
||||||
// A 模式编辑 → peqChange;B 模式编辑(含切换后展示)→ peqApply(防抖)
|
// A/B 切换与曲线编辑防抖同步 → peqApply(仅试听,不写预设)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (syncingHeadphoneRef.current) return;
|
if (syncingHeadphoneRef.current) return;
|
||||||
if (skipPeqAutoSyncRef.current) {
|
if (skipPeqAutoSyncRef.current) {
|
||||||
@@ -1394,7 +1392,7 @@ export default function EQPage() {
|
|||||||
window.clearTimeout(peqSyncTimerRef.current);
|
window.clearTimeout(peqSyncTimerRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [bands, abMode, headphoneIdx, peqItems, api, isDemoMode, upgradePeqChange, upgradePeqApply]);
|
}, [bands, abMode, headphoneIdx, peqItems, api, isDemoMode, upgradePeqApply]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPointerDown = (event: PointerEvent) => {
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
@@ -1575,10 +1573,34 @@ export default function EQPage() {
|
|||||||
<ChevronLeft size={24} />
|
<ChevronLeft size={24} />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">HP-EQ</h1>
|
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">HP-EQ</h1>
|
||||||
<IOSToggle checked={eqOn} onChange={(v) => updateSetting({ peqEnable: v ? 1 : 0 })} />
|
<IOSToggle
|
||||||
|
checked={eqOn}
|
||||||
|
onChange={(v) => {
|
||||||
|
if (v) {
|
||||||
|
void updateSetting(
|
||||||
|
bypassOn ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
void updateSetting({ peqEnable: 0 });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 pb-32 pt-3 space-y-3">
|
<FeatureGate
|
||||||
|
enabled={eqOn}
|
||||||
|
className="px-4 pb-32 pt-3 space-y-3"
|
||||||
|
labels={{
|
||||||
|
title: eqUi.enableConfirmTitle ?? "HP-EQ 未开启",
|
||||||
|
description: eqUi.enableConfirmDesc ?? "当前 HP-EQ 功能已关闭,是否开启?",
|
||||||
|
cancel: eqUi.cancel ?? "取消",
|
||||||
|
confirm: eqUi.enableConfirmOk ?? "开启",
|
||||||
|
}}
|
||||||
|
onEnable={() => {
|
||||||
|
const bypass = (deviceState?.dsp_enable ?? 0) === 0;
|
||||||
|
return updateSetting(bypass ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 });
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* ── Action cards: 批量编辑 / 添加耳机型号 ── */}
|
{/* ── Action cards: 批量编辑 / 添加耳机型号 ── */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -1840,7 +1862,7 @@ export default function EQPage() {
|
|||||||
onChange={(v) => updateCurrentPeqMeta({ preamp: Number((v - 15).toFixed(1)) })}
|
onChange={(v) => updateCurrentPeqMeta({ preamp: Number((v - 15).toFixed(1)) })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FeatureGate>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSaveBDialogOpen && (
|
{isSaveBDialogOpen && (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
|
|||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { useState, useEffect, useRef, useMemo } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import BottomNav from "@/components/BottomNav";
|
import BottomNav from "@/components/BottomNav";
|
||||||
|
import { FeatureGate } from "@/components/FeatureGate";
|
||||||
import { navigateToSelect } from "./SelectPage";
|
import { navigateToSelect } from "./SelectPage";
|
||||||
import localeZh from "@/locales/data-zh.json";
|
import localeZh from "@/locales/data-zh.json";
|
||||||
import localeZhHK from "@/locales/data-zh-HK.json";
|
import localeZhHK from "@/locales/data-zh-HK.json";
|
||||||
@@ -85,7 +86,9 @@ export default function EffectsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ds = deviceState;
|
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 widthOn = (ds?.width_enable ?? 0) === 1;
|
||||||
const widthVal = ds?.width_value ?? 50;
|
const widthVal = ds?.width_value ?? 50;
|
||||||
const crossfeedOn = (ds?.crossfeed_enable ?? 0) === 1;
|
const crossfeedOn = (ds?.crossfeed_enable ?? 0) === 1;
|
||||||
@@ -152,10 +155,34 @@ export default function EffectsPage() {
|
|||||||
<ChevronLeft size={24} />
|
<ChevronLeft size={24} />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">{effectText.pageTitle}</h1>
|
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">{effectText.pageTitle}</h1>
|
||||||
<IOSToggle checked={effectOn} onChange={(v) => updateSetting({ audio_enable: v ? 1 : 0 })} />
|
<IOSToggle
|
||||||
|
checked={effectOn}
|
||||||
|
onChange={(v) => {
|
||||||
|
if (v) {
|
||||||
|
void updateSetting(
|
||||||
|
bypassOn ? { audio_enable: 1, dsp_enable: 1 } : { audio_enable: 1 },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
void updateSetting({ audio_enable: 0 });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 pb-32 pt-3 space-y-3">
|
<FeatureGate
|
||||||
|
enabled={effectOn}
|
||||||
|
className="px-4 pb-32 pt-3 space-y-3"
|
||||||
|
labels={{
|
||||||
|
title: effectText.enableConfirmTitle ?? "音效未开启",
|
||||||
|
description: effectText.enableConfirmDesc ?? "当前 Effect 功能已关闭,是否开启?",
|
||||||
|
cancel: effectText.enableConfirmCancel ?? "取消",
|
||||||
|
confirm: effectText.enableConfirmOk ?? "开启",
|
||||||
|
}}
|
||||||
|
onEnable={() => {
|
||||||
|
const bypass = (ds?.dsp_enable ?? 0) === 0;
|
||||||
|
return updateSetting(bypass ? { audio_enable: 1, dsp_enable: 1 } : { audio_enable: 1 });
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* ── Style ── */}
|
{/* ── Style ── */}
|
||||||
<div className="ios-list-group p-4">
|
<div className="ios-list-group p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
@@ -362,7 +389,7 @@ export default function EffectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FeatureGate>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BottomNav />
|
<BottomNav />
|
||||||
|
|||||||
+136
-17
@@ -52,6 +52,39 @@ function volToDB(v: number) {
|
|||||||
return `${db >= 0 ? "+" : ""}${db.toFixed(1)}dB`;
|
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 ──
|
// ── iOS-style Toggle ──
|
||||||
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||||
return (
|
return (
|
||||||
@@ -106,6 +139,11 @@ 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 [easterEggActive, setEasterEggActive] = useState(false);
|
||||||
|
const [vuHeights, setVuHeights] = useState<number[]>(idleVuBarHeights);
|
||||||
|
const jackProgressRef = useRef(0);
|
||||||
|
const jackResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const eggRafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const homeText = useMemo((): HomeLocale => {
|
const homeText = useMemo((): HomeLocale => {
|
||||||
if (!deviceState) return {} as HomeLocale;
|
if (!deviceState) return {} as HomeLocale;
|
||||||
@@ -174,10 +212,15 @@ export default function Home() {
|
|||||||
hpEqNavigateTimerRef.current = setTimeout(() => {
|
hpEqNavigateTimerRef.current = setTimeout(() => {
|
||||||
hpEqNavigateTimerRef.current = null;
|
hpEqNavigateTimerRef.current = null;
|
||||||
hpEqLastTapRef.current = null;
|
hpEqLastTapRef.current = null;
|
||||||
|
const bypassOn = (deviceState?.dsp_enable ?? 0) === 0;
|
||||||
|
if (bypassOn) {
|
||||||
|
void updateSetting({ peqEnable: 1, dsp_enable: 1 });
|
||||||
|
} else {
|
||||||
const on = (deviceState?.peqEnable ?? 0) === 1;
|
const on = (deviceState?.peqEnable ?? 0) === 1;
|
||||||
void updateSetting({ peqEnable: on ? 0 : 1 });
|
void updateSetting({ peqEnable: on ? 0 : 1 });
|
||||||
|
}
|
||||||
}, HP_EQ_DOUBLE_CLICK_MS);
|
}, HP_EQ_DOUBLE_CLICK_MS);
|
||||||
}, [deviceState?.peqEnable, updateSetting, setLocation]);
|
}, [deviceState?.peqEnable, deviceState?.dsp_enable, updateSetting, setLocation]);
|
||||||
|
|
||||||
/** Effect 圆钮:单击短延迟后切换 audio_enable;280ms 内第二次点击则进入 /effects */
|
/** Effect 圆钮:单击短延迟后切换 audio_enable;280ms 内第二次点击则进入 /effects */
|
||||||
const handleEffectCircleClick = useCallback(() => {
|
const handleEffectCircleClick = useCallback(() => {
|
||||||
@@ -197,10 +240,15 @@ export default function Home() {
|
|||||||
effectNavigateTimerRef.current = setTimeout(() => {
|
effectNavigateTimerRef.current = setTimeout(() => {
|
||||||
effectNavigateTimerRef.current = null;
|
effectNavigateTimerRef.current = null;
|
||||||
effectLastTapRef.current = null;
|
effectLastTapRef.current = null;
|
||||||
|
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;
|
const on = (deviceState?.audio_enable ?? 0) === 1;
|
||||||
void updateSetting({ audio_enable: on ? 0 : 1 });
|
void updateSetting({ audio_enable: on ? 0 : 1 });
|
||||||
|
}
|
||||||
}, HP_EQ_DOUBLE_CLICK_MS);
|
}, HP_EQ_DOUBLE_CLICK_MS);
|
||||||
}, [deviceState?.audio_enable, updateSetting, setLocation]);
|
}, [deviceState?.audio_enable, deviceState?.dsp_enable, updateSetting, setLocation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
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 <ConnectScreen />;
|
if (!isConnected) return <ConnectScreen />;
|
||||||
|
|
||||||
const ds = deviceState!;
|
const ds = deviceState!;
|
||||||
@@ -305,34 +405,43 @@ export default function Home() {
|
|||||||
{/* Left ports */}
|
{/* Left ports */}
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Big hole (left) */}
|
{/* Big hole (left) — 彩蛋第 1 步 */}
|
||||||
<div
|
<button
|
||||||
className="w-7 h-7 rounded-full border-2 bg-black/25 flex items-center justify-center"
|
type="button"
|
||||||
|
className="w-7 h-7 rounded-full border-2 bg-black/25 flex items-center justify-center cursor-pointer active:scale-95 transition-transform"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "rgba(245, 158, 11, 0.75)",
|
borderColor: "rgba(245, 158, 11, 0.75)",
|
||||||
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.35)",
|
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.35)",
|
||||||
}}
|
}}
|
||||||
|
aria-label="Headphone jack"
|
||||||
|
onClick={() => onJackClick("lg")}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-2 h-2 rounded-full"
|
className="w-2 h-2 rounded-full pointer-events-none"
|
||||||
style={{ background: "rgba(196, 126, 9, 0.9)" }}
|
style={{ background: "rgba(196, 126, 9, 0.9)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
{/* Two small holes (right, stacked) */}
|
{/* Two small holes (right, stacked) */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div
|
<button
|
||||||
className="w-5 h-5 rounded-full border-2 bg-black/25"
|
type="button"
|
||||||
|
className="w-5 h-5 rounded-full border-2 bg-black/25 cursor-pointer active:scale-95 transition-transform"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "rgba(245, 158, 11, 0.65)",
|
borderColor: "rgba(245, 158, 11, 0.65)",
|
||||||
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.35)",
|
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.35)",
|
||||||
}}
|
}}
|
||||||
|
aria-label="Headphone jack"
|
||||||
|
onClick={() => onJackClick("smTop")}
|
||||||
/>
|
/>
|
||||||
<div
|
<button
|
||||||
className="w-5 h-5 rounded-full border-2 bg-black/25"
|
type="button"
|
||||||
|
className="w-5 h-5 rounded-full border-2 bg-black/25 cursor-pointer active:scale-95 transition-transform"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "rgba(245, 158, 11, 0.65)",
|
borderColor: "rgba(245, 158, 11, 0.65)",
|
||||||
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.35)",
|
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.35)",
|
||||||
}}
|
}}
|
||||||
|
aria-label="Headphone jack"
|
||||||
|
onClick={() => onJackClick("smBot")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,17 +454,27 @@ export default function Home() {
|
|||||||
<span className="text-[9px] text-white/50">{volToDB(localVol)}</span>
|
<span className="text-[9px] text-white/50">{volToDB(localVol)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Mini VU bars */}
|
{/* Mini VU bars */}
|
||||||
<div className="flex gap-0.5 px-2 pt-1 pb-2 h-16 items-end">
|
<div
|
||||||
{Array.from({ length: 20 }).map((_, i) => {
|
className={cn(
|
||||||
const h = Math.max(0.1, Math.sin((i / 20) * Math.PI) * 0.8 + Math.random() * 0.2);
|
"flex gap-0.5 px-2 pt-1 pb-2 h-16 items-end",
|
||||||
|
easterEggActive && "brightness-110",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{vuHeights.map((h, i) => {
|
||||||
const isHot = i > 16;
|
const isHot = i > 16;
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex-1 rounded-sm"
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 rounded-sm"
|
||||||
style={{
|
style={{
|
||||||
height: `${h * 100}%`,
|
height: `${h * 100}%`,
|
||||||
background: isHot ? "#ef4444" : i > 13 ? "#f59e0b" : "#00FFF6",
|
background: isHot ? "#ef4444" : i > 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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user