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 {
|
export interface PeqState {
|
||||||
filters: PeqFilter[];
|
filters: PeqFilter[];
|
||||||
|
peq?: Array<{
|
||||||
|
name: string;
|
||||||
|
filters?: PeqFilter[];
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
+60
-14
@@ -13,7 +13,7 @@
|
|||||||
import { useDevice } from "@/contexts/DeviceContext";
|
import { useDevice } from "@/contexts/DeviceContext";
|
||||||
import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones } from "lucide-react";
|
import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones } from "lucide-react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import BottomNav from "@/components/BottomNav";
|
import BottomNav from "@/components/BottomNav";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -47,14 +47,6 @@ function CyanSlider({ value, min, max, step = 1, onChange }: {
|
|||||||
|
|
||||||
/* ── Constants ── */
|
/* ── Constants ── */
|
||||||
const FILTER_TYPES = ["PEAK", "LSHELF", "HSHELF", "NOTCH", "LPF", "HPF"];
|
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 ── */
|
/* ── Frequency Response Chart ── */
|
||||||
function FreqChart({
|
function FreqChart({
|
||||||
@@ -211,15 +203,69 @@ function freqLabel(f: number) {
|
|||||||
/* ── Main Component ── */
|
/* ── Main Component ── */
|
||||||
export default function EQPage() {
|
export default function EQPage() {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const { deviceState, updateSetting } = useDevice();
|
const { deviceState, updateSetting, api, isDemoMode } = useDevice();
|
||||||
const eqOn = (deviceState?.peqEnable ?? 0) === 1;
|
const eqOn = (deviceState?.peqEnable ?? 0) === 1;
|
||||||
|
|
||||||
const [bands, setBands] = useState(DEFAULT_BANDS);
|
const [bands, setBands] = useState(DEFAULT_BANDS);
|
||||||
const [selectedBand, setSelectedBand] = useState(3);
|
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 [abMode, setAbMode] = useState<"A" | "B">("A");
|
||||||
const [autoGain, setAutoGain] = useState(false);
|
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 band = bands[selectedBand];
|
||||||
|
|
||||||
const updateBand = (idx: number, patch: Partial<typeof bands[0]>) => {
|
const updateBand = (idx: number, patch: Partial<typeof bands[0]>) => {
|
||||||
@@ -271,10 +317,10 @@ export default function EQPage() {
|
|||||||
<button
|
<button
|
||||||
className="flex items-center gap-2 active:opacity-70 transition-opacity"
|
className="flex items-center gap-2 active:opacity-70 transition-opacity"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigateToSelect("选择耳机型号", HEADPHONE_MODELS, headphoneIdx, "/eq", "headphone");
|
navigateToSelect("选择耳机型号", headphoneModels, headphoneIdx, "/eq", "headphone");
|
||||||
setLocation("/select");
|
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" />
|
<ChevronDown size={18} className="text-white/50" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -287,7 +333,7 @@ export default function EQPage() {
|
|||||||
<button
|
<button
|
||||||
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
|
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)" }}
|
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} />
|
<Plus size={15} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ export default function EffectsPage() {
|
|||||||
const colorMidGain = (ds?.color_mid_gain ?? 0) / 10;
|
const colorMidGain = (ds?.color_mid_gain ?? 0) / 10;
|
||||||
const colorTrebleGain = (ds?.color_treble_gain ?? 0) / 10;
|
const colorTrebleGain = (ds?.color_treble_gain ?? 0) / 10;
|
||||||
const loudnessOn = (ds?.loudness_enable ?? 0) === 1;
|
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 [localBass, setLocalBass] = useState(colorBassGain);
|
||||||
const [localMid, setLocalMid] = useState(colorMidGain);
|
const [localMid, setLocalMid] = useState(colorMidGain);
|
||||||
@@ -74,11 +77,21 @@ export default function EffectsPage() {
|
|||||||
const isDraggingMid = useRef(false);
|
const isDraggingMid = useRef(false);
|
||||||
const isDraggingTreble = 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(() => {
|
useEffect(() => {
|
||||||
if (!isDraggingBass.current) setLocalBass(colorBassGain);
|
if (!isDraggingBass.current) setLocalBass(colorBassGain);
|
||||||
if (!isDraggingMid.current) setLocalMid(colorMidGain);
|
if (!isDraggingMid.current) setLocalMid(colorMidGain);
|
||||||
if (!isDraggingTreble.current) setLocalTreble(colorTrebleGain);
|
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 [localWidth, setLocalWidth] = useState(widthVal);
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
@@ -234,6 +247,76 @@ export default function EffectsPage() {
|
|||||||
<span className="text-[16px] font-medium text-white">响度</span>
|
<span className="text-[16px] font-medium text-white">响度</span>
|
||||||
<IOSToggle checked={loudnessOn} onChange={(v) => updateSetting({ loudness_enable: v ? 1 : 0 })} />
|
<IOSToggle checked={loudnessOn} onChange={(v) => updateSetting({ loudness_enable: v ? 1 : 0 })} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const KEY_TO_SETTING: Record<string, string> = {
|
|||||||
buttonLight: "buttonLight",
|
buttonLight: "buttonLight",
|
||||||
autoHome: "autoHome",
|
autoHome: "autoHome",
|
||||||
sleep: "sleep",
|
sleep: "sleep",
|
||||||
|
headphone: "peqSelect",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Global state for select page (to avoid URL params)
|
// Global state for select page (to avoid URL params)
|
||||||
|
|||||||
+32
-59
@@ -14,62 +14,7 @@ interface VUStyle {
|
|||||||
index: number;
|
index: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
preview: React.ReactNode;
|
preview: string;
|
||||||
}
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const VU_STYLES: VUStyle[] = [
|
const VU_STYLES: VUStyle[] = [
|
||||||
@@ -77,13 +22,37 @@ const VU_STYLES: VUStyle[] = [
|
|||||||
index: 0,
|
index: 0,
|
||||||
name: "经典复古",
|
name: "经典复古",
|
||||||
description: "琥珀色指针表盘,经典模拟风格",
|
description: "琥珀色指针表盘,经典模拟风格",
|
||||||
preview: <ClassicVUPreview />,
|
preview: "/vu/vu1.png",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 1,
|
index: 1,
|
||||||
name: "现代平面",
|
name: "现代平面",
|
||||||
description: "LED 电平条,简洁现代风格",
|
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)" } : {}}
|
style={isActive ? { borderColor: "rgba(0,255,246,0.3)", boxShadow: "0 0 20px rgba(0,255,246,0.08)" } : {}}
|
||||||
onClick={() => updateSetting({ vu: style.index })}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Selection indicator */}
|
{/* Selection indicator */}
|
||||||
|
|||||||
Reference in New Issue
Block a user