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:
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 |
@@ -114,6 +114,10 @@ export interface PeqFilter {
|
||||
|
||||
export interface PeqState {
|
||||
filters: PeqFilter[];
|
||||
peq?: Array<{
|
||||
name: string;
|
||||
filters?: PeqFilter[];
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
+60
-14
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user