Enhance audio settings: Added loudness controls and headphone model synchronization. Updated PeqState interface to include peq filters. Improved EQPage to dynamically load headphone models based on device state.

This commit is contained in:
yangy
2026-04-01 16:29:52 +08:00
parent 30db710093
commit 502f708336
11 changed files with 181 additions and 74 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+4
View File
@@ -114,6 +114,10 @@ export interface PeqFilter {
export interface PeqState {
filters: PeqFilter[];
peq?: Array<{
name: string;
filters?: PeqFilter[];
}>;
}
// ============================================================
+60 -14
View File
@@ -13,7 +13,7 @@
import { useDevice } from "@/contexts/DeviceContext";
import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones } from "lucide-react";
import { useLocation } from "wouter";
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { cn } from "@/lib/utils";
import BottomNav from "@/components/BottomNav";
import { toast } from "sonner";
@@ -47,14 +47,6 @@ function CyanSlider({ value, min, max, step = 1, onChange }: {
/* ── Constants ── */
const FILTER_TYPES = ["PEAK", "LSHELF", "HSHELF", "NOTCH", "LPF", "HPF"];
const HEADPHONE_MODELS = [
"Sennheiser HD 650",
"Sennheiser HD 800",
"Sony WH-1000XM5",
"AKG K701",
"Beyerdynamic DT 990",
"Audio-Technica ATH-M50x",
];
/* ── Frequency Response Chart ── */
function FreqChart({
@@ -211,15 +203,69 @@ function freqLabel(f: number) {
/* ── Main Component ── */
export default function EQPage() {
const [, setLocation] = useLocation();
const { deviceState, updateSetting } = useDevice();
const { deviceState, updateSetting, api, isDemoMode } = useDevice();
const eqOn = (deviceState?.peqEnable ?? 0) === 1;
const [bands, setBands] = useState(DEFAULT_BANDS);
const [selectedBand, setSelectedBand] = useState(3);
const [headphoneIdx, setHeadphoneIdx] = useState(0);
const [headphoneIdx, setHeadphoneIdx] = useState(deviceState?.peqSelect ?? 0);
const [headphoneModels, setHeadphoneModels] = useState<string[]>([]);
const [abMode, setAbMode] = useState<"A" | "B">("A");
const [autoGain, setAutoGain] = useState(false);
// 同步 peqSelect 变化
useEffect(() => {
if (deviceState?.peqSelect !== undefined) {
setHeadphoneIdx(deviceState.peqSelect);
}
}, [deviceState?.peqSelect]);
// 加载耳机列表
useEffect(() => {
async function loadHeadphones() {
if (isDemoMode || !api) {
// 演示模式使用默认列表
setHeadphoneModels([
"Sennheiser HD 650",
"Sennheiser HD 800",
"Sony WH-1000XM5",
"AKG K701",
"Beyerdynamic DT 990",
"Audio-Technica ATH-M50x",
]);
return;
}
try {
const peqState = await api.getPeqState();
if (peqState.peq && peqState.peq.length > 0) {
setHeadphoneModels(peqState.peq.map(h => h.name));
} else {
// 如果没有数据,使用默认列表
setHeadphoneModels([
"Sennheiser HD 650",
"Sennheiser HD 800",
"Sony WH-1000XM5",
"AKG K701",
"Beyerdynamic DT 990",
"Audio-Technica ATH-M50x",
]);
}
} catch (error) {
console.error("Failed to load headphone models:", error);
// 出错时使用默认列表
setHeadphoneModels([
"Sennheiser HD 650",
"Sennheiser HD 800",
"Sony WH-1000XM5",
"AKG K701",
"Beyerdynamic DT 990",
"Audio-Technica ATH-M50x",
]);
}
}
loadHeadphones();
}, [api, isDemoMode]);
const band = bands[selectedBand];
const updateBand = (idx: number, patch: Partial<typeof bands[0]>) => {
@@ -271,10 +317,10 @@ export default function EQPage() {
<button
className="flex items-center gap-2 active:opacity-70 transition-opacity"
onClick={() => {
navigateToSelect("选择耳机型号", HEADPHONE_MODELS, headphoneIdx, "/eq", "headphone");
navigateToSelect("选择耳机型号", headphoneModels, headphoneIdx, "/eq", "headphone");
setLocation("/select");
}}>
<span className="text-[20px] font-bold text-white">{HEADPHONE_MODELS[headphoneIdx]}</span>
<span className="text-[20px] font-bold text-white">{headphoneModels[headphoneIdx]}</span>
<ChevronDown size={18} className="text-white/50" />
</button>
<div className="flex items-center gap-2">
@@ -287,7 +333,7 @@ export default function EQPage() {
<button
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.8)", border: "1px solid rgba(255,255,255,0.1)" }}
onClick={() => setHeadphoneIdx((i) => Math.min(HEADPHONE_MODELS.length - 1, i + 1))}>
onClick={() => setHeadphoneIdx((i) => Math.min(headphoneModels.length - 1, i + 1))}>
<Plus size={15} />
</button>
</div>
+84 -1
View File
@@ -66,6 +66,9 @@ export default function EffectsPage() {
const colorMidGain = (ds?.color_mid_gain ?? 0) / 10;
const colorTrebleGain = (ds?.color_treble_gain ?? 0) / 10;
const loudnessOn = (ds?.loudness_enable ?? 0) === 1;
const loudnessThreshold = ds?.loudness_threshold_gain ?? 0;
const loudnessBassGain = (ds?.loudness_bass_gain ?? 0) / 10;
const loudnessTrebleGain = (ds?.loudness_treble_gain ?? 0) / 10;
const [localBass, setLocalBass] = useState(colorBassGain);
const [localMid, setLocalMid] = useState(colorMidGain);
@@ -74,11 +77,21 @@ export default function EffectsPage() {
const isDraggingMid = useRef(false);
const isDraggingTreble = useRef(false);
const [localLoudnessThreshold, setLocalLoudnessThreshold] = useState(loudnessThreshold);
const [localLoudnessBass, setLocalLoudnessBass] = useState(loudnessBassGain);
const [localLoudnessTreble, setLocalLoudnessTreble] = useState(loudnessTrebleGain);
const isDraggingLoudnessThreshold = useRef(false);
const isDraggingLoudnessBass = useRef(false);
const isDraggingLoudnessTreble = useRef(false);
useEffect(() => {
if (!isDraggingBass.current) setLocalBass(colorBassGain);
if (!isDraggingMid.current) setLocalMid(colorMidGain);
if (!isDraggingTreble.current) setLocalTreble(colorTrebleGain);
}, [colorBassGain, colorMidGain, colorTrebleGain]);
if (!isDraggingLoudnessThreshold.current) setLocalLoudnessThreshold(loudnessThreshold);
if (!isDraggingLoudnessBass.current) setLocalLoudnessBass(loudnessBassGain);
if (!isDraggingLoudnessTreble.current) setLocalLoudnessTreble(loudnessTrebleGain);
}, [colorBassGain, colorMidGain, colorTrebleGain, loudnessThreshold, loudnessBassGain, loudnessTrebleGain]);
const [localWidth, setLocalWidth] = useState(widthVal);
const isDragging = useRef(false);
@@ -234,6 +247,76 @@ export default function EffectsPage() {
<span className="text-[16px] font-medium text-white"></span>
<IOSToggle checked={loudnessOn} onChange={(v) => updateSetting({ loudness_enable: v ? 1 : 0 })} />
</div>
{loudnessOn && (
<div className="space-y-4 mt-4">
{/* 阈值 */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[14px] text-white/60"></span>
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>{localLoudnessThreshold}</span>
</div>
<CyanSlider
value={localLoudnessThreshold}
min={-30}
max={0}
step={1}
onChange={(v) => {
isDraggingLoudnessThreshold.current = true;
setLocalLoudnessThreshold(v);
}}
onMouseUp={() => {
isDraggingLoudnessThreshold.current = false;
updateSetting({ loudness_threshold_gain: Math.round(localLoudnessThreshold) });
}}
/>
</div>
{/* 低音 */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[14px] text-white/60"></span>
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>{localLoudnessBass.toFixed(1)}</span>
</div>
<CyanSlider
value={localLoudnessBass}
min={0}
max={10}
step={0.1}
onChange={(v) => {
isDraggingLoudnessBass.current = true;
setLocalLoudnessBass(v);
}}
onMouseUp={() => {
isDraggingLoudnessBass.current = false;
updateSetting({ loudness_bass_gain: Math.round(localLoudnessBass * 10) });
}}
/>
</div>
{/* 高音 */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[14px] text-white/60"></span>
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>{localLoudnessTreble.toFixed(1)}</span>
</div>
<CyanSlider
value={localLoudnessTreble}
min={0}
max={10}
step={0.1}
onChange={(v) => {
isDraggingLoudnessTreble.current = true;
setLocalLoudnessTreble(v);
}}
onMouseUp={() => {
isDraggingLoudnessTreble.current = false;
updateSetting({ loudness_treble_gain: Math.round(localLoudnessTreble * 10) });
}}
/>
</div>
</div>
)}
</div>
</div>
</div>
+1
View File
@@ -33,6 +33,7 @@ const KEY_TO_SETTING: Record<string, string> = {
buttonLight: "buttonLight",
autoHome: "autoHome",
sleep: "sleep",
headphone: "peqSelect",
};
// Global state for select page (to avoid URL params)
+32 -59
View File
@@ -14,62 +14,7 @@ interface VUStyle {
index: number;
name: string;
description: string;
preview: React.ReactNode;
}
// Classic analog VU meter preview
function ClassicVUPreview() {
return (
<div className="w-full h-28 rounded-xl overflow-hidden flex gap-1 p-2" style={{ background: "#1a1208" }}>
{["L", "R"].map((ch) => (
<div key={ch} className="flex-1 rounded-lg overflow-hidden relative" style={{ background: "#2a1e08" }}>
{/* Amber gradient arc area */}
<div className="absolute inset-0 flex items-end justify-center pb-2">
<div className="w-full h-full relative">
{/* Scale marks */}
<div className="absolute top-1 left-0 right-0 flex justify-around px-2">
{["-20","-10","-7","-5","-3","0","+3"].map((v) => (
<span key={v} className="text-[6px] text-amber-300/70">{v}</span>
))}
</div>
{/* Needle */}
<div className="absolute bottom-3 left-1/2 w-0.5 h-14 origin-bottom"
style={{ background: "linear-gradient(to top, #dc2626, #ef4444)", transform: "translateX(-50%) rotate(-15deg)", borderRadius: "1px" }} />
{/* VU label */}
<div className="absolute bottom-1 left-1/2 -translate-x-1/2 text-[8px] font-bold text-amber-400">VU</div>
{/* dB label */}
<div className="absolute bottom-1 right-2 text-[7px] text-amber-300/60">dB</div>
</div>
</div>
</div>
))}
</div>
);
}
// Modern flat LED bar preview
function ModernVUPreview() {
const bars = [0.9, 0.8, 0.75, 0.65, 0.55, 0.45, 0.35, 0.25];
return (
<div className="w-full h-28 rounded-xl overflow-hidden flex gap-1 p-3" style={{ background: "#0f1a1a" }}>
{["L", "R"].map((ch) => (
<div key={ch} className="flex-1 flex flex-col gap-1 justify-end">
<div className="text-[8px] text-white/40 mb-1">{ch}</div>
<div className="flex gap-0.5 items-end h-16">
{bars.map((h, i) => (
<div key={i} className="flex-1 rounded-sm"
style={{
height: `${h * 100}%`,
background: i < 6 ? "#00FFF6" : i === 6 ? "#f59e0b" : "#ef4444",
opacity: 0.8,
}} />
))}
</div>
<div className="text-[8px] text-white/40 text-right">dB</div>
</div>
))}
</div>
);
preview: string;
}
const VU_STYLES: VUStyle[] = [
@@ -77,13 +22,37 @@ const VU_STYLES: VUStyle[] = [
index: 0,
name: "经典复古",
description: "琥珀色指针表盘,经典模拟风格",
preview: <ClassicVUPreview />,
preview: "/vu/vu1.png",
},
{
index: 1,
name: "现代平面",
description: "LED 电平条,简洁现代风格",
preview: <ModernVUPreview />,
preview: "/vu/vu2.png",
},
{
index: 2,
name: "数字显示",
description: "数字电平显示,精准直观",
preview: "/vu/vu3.png",
},
{
index: 3,
name: "条形图",
description: "垂直条形图,专业风格",
preview: "/vu/vu4.png",
},
{
index: 4,
name: "渐变光谱",
description: "彩虹渐变光谱,炫酷动感",
preview: "/vu/vu5.png",
},
{
index: 5,
name: "频谱分析",
description: "实时频谱分析,专业监测",
preview: "/vu/vu6.png",
},
];
@@ -116,7 +85,11 @@ export default function VUPage() {
style={isActive ? { borderColor: "rgba(0,255,246,0.3)", boxShadow: "0 0 20px rgba(0,255,246,0.08)" } : {}}
onClick={() => updateSetting({ vu: style.index })}
>
{style.preview}
<img
src={style.preview}
alt={style.name}
className="w-full h-28 object-contain rounded-xl bg-black/20"
/>
</div>
{/* Selection indicator */}