x9适配
This commit is contained in:
@@ -2,9 +2,8 @@
|
||||
AUDIO PAGE — Audio Settings
|
||||
Design: Reference luxsin_x8_音频设置.png
|
||||
Sections:
|
||||
- 平衡与灵敏度: 左右平衡 slider, VU灵敏度 slider
|
||||
- 平衡与灵敏度: 左右平衡, VU灵敏度, 模拟输入增益 sliders
|
||||
- 输出特性: 滤波特性, 耳机增益, 音量幅度
|
||||
- IIS设置: IIS静音电平, IIS模式
|
||||
- 系统与接口: 开机音量, XLR极性
|
||||
All list rows → navigate to /select page
|
||||
============================================================ */
|
||||
@@ -70,7 +69,31 @@ function SectionHeader({ title }: { title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ListRow({ label, value, onClick }: { label: string; value: string; onClick?: () => void }) {
|
||||
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="ios-toggle" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
<span className="ios-toggle-track"><span className="ios-toggle-thumb" /></span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ListRow({ label, value, onClick, toggle, checked, onToggle }: {
|
||||
label: string;
|
||||
value?: string;
|
||||
onClick?: () => void;
|
||||
toggle?: boolean;
|
||||
checked?: boolean;
|
||||
onToggle?: (v: boolean) => void;
|
||||
}) {
|
||||
if (toggle) {
|
||||
return (
|
||||
<div className="ios-list-row">
|
||||
<span className="flex-1 text-[16px] text-white">{label}</span>
|
||||
<IOSToggle checked={!!checked} onChange={onToggle ?? (() => {})} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button className="ios-list-row w-full text-left active:bg-white/5 transition-colors" onClick={onClick}>
|
||||
<span className="flex-1 text-[16px] text-white">{label}</span>
|
||||
@@ -84,17 +107,18 @@ type LocaleDac = {
|
||||
dac?: {
|
||||
sectionBalanceSensitivity?: string;
|
||||
sectionOutput?: string;
|
||||
sectionIisSettings?: string;
|
||||
sectionSystemInterface?: string;
|
||||
balance?: string;
|
||||
sensitivity?: string;
|
||||
analogInputGain?: string;
|
||||
autoImpedanceDetection?: string;
|
||||
filters?: { label?: string; options?: Array<{ index: number; label: string }> };
|
||||
dacGain?: { label?: string; options?: Array<{ index: number; label: string }> };
|
||||
dacVolumeDirect?: { label?: string; options?: Array<{ index: number; label: string }> };
|
||||
dacArc?: { label?: string; options?: Array<{ index: number; label: string }> };
|
||||
volumeStep?: string;
|
||||
bootVolume?: string | { label?: string; options?: Array<{ index: number; label: string }> };
|
||||
xlr?: { label?: string; options?: Array<{ index: number; label: string }> };
|
||||
mutePolar?: { label?: string; options?: Array<{ index: number; label: string }> };
|
||||
IISMode?: { label?: string; options?: Array<{ index: number; label: string }> };
|
||||
};
|
||||
home?: { audioSet?: string };
|
||||
};
|
||||
@@ -166,15 +190,22 @@ export default function AudioPage() {
|
||||
const balanceVal = ds?.balance !== undefined ? ds.balance / 10 : 0;
|
||||
// vuSensor 从接口获取的是放大 2 倍的值,需要除以 2 显示(例如:20 → +10dB)
|
||||
const vuSensVal = vuSensor !== undefined ? vuSensor / 2 : 0;
|
||||
// analogGain 从接口获取的是放大 2 倍的值,需要除以 2 显示(例如:-6 → -3dB)
|
||||
const analogGainVal = ds?.analogGain !== undefined ? ds.analogGain / 2 : 0;
|
||||
const bootSoundFromApi = readBootSoundFromState(ds);
|
||||
const [localBootSound, setLocalBootSound] = useState(bootSoundFromApi);
|
||||
const [localBalance, setLocalBalance] = useState(balanceVal);
|
||||
const [localVuSens, setLocalVuSens] = useState(0);
|
||||
const [localAnalogGain, setLocalAnalogGain] = useState(analogGainVal);
|
||||
const isBalanceDragging = useRef(false);
|
||||
const isVuDragging = useRef(false);
|
||||
const isAnalogGainDragging = useRef(false);
|
||||
|
||||
useEffect(() => { if (!isBalanceDragging.current) setLocalBalance(balanceVal); }, [balanceVal]);
|
||||
useEffect(() => { if (!isVuDragging.current) setLocalVuSens(vuSensVal); }, [vuSensVal]);
|
||||
useEffect(() => {
|
||||
if (!isAnalogGainDragging.current) setLocalAnalogGain(analogGainVal);
|
||||
}, [analogGainVal]);
|
||||
useEffect(() => {
|
||||
setLocalBootSound(readBootSoundFromState(ds));
|
||||
}, [ds?.bootSound]);
|
||||
@@ -192,11 +223,12 @@ export default function AudioPage() {
|
||||
}, [location, isConnected, api, refresh]);
|
||||
|
||||
const gainIdx = ds?.dacGain ?? 0;
|
||||
const autoImpedanceOn = (ds?.dacImpedance ?? 0) === 1;
|
||||
const dacVolumeDirectIdx = ds?.dacVolumeDirect ?? 0;
|
||||
const dacArcIdx = ds?.dacArc ?? 0;
|
||||
const stepIdx = ds?.soundStep ?? 1;
|
||||
const filterIdx = ds?.pcm ?? 0;
|
||||
const xlrIdx = ds?.xlr ?? 0;
|
||||
const mutePolarIdx = (ds as ({ hdmimutepolar?: number } | null))?.hdmimutepolar ?? 0;
|
||||
const iisModeIdx = (ds as ({ hdmiType?: number } | null))?.hdmiType ?? 0;
|
||||
const bootSoundVal = normalizeBootSound(localBootSound);
|
||||
const firmwareVersion = parseFirmwareVersion(ds?.version);
|
||||
const bootSoundMaxIdx = getBootSoundMaxIndex(firmwareVersion);
|
||||
@@ -205,9 +237,9 @@ export default function AudioPage() {
|
||||
|
||||
const FILTER_OPTIONS = optionLabels(dacText.filters?.options, ["快速滚降", "慢速滚降", "短延迟快速滚降", "短延迟慢速滚降", "去重强调", "非过采样(NOS)"]);
|
||||
const GAIN_OPTIONS = optionLabels(dacText.dacGain?.options, ["低", "中", "高"]);
|
||||
const DAC_VOLUME_DIRECT_OPTIONS = optionLabels(dacText.dacVolumeDirect?.options, ["关闭", "0dB", "-12dB"]);
|
||||
const DAC_ARC_OPTIONS = optionLabels(dacText.dacArc?.options, ["ARC", "EARC"]);
|
||||
const XLR_OPTIONS = optionLabels(dacText.xlr?.options, ["正常", "反转"]);
|
||||
const MUTE_POLAR_OPTIONS = optionLabels(dacText.mutePolar?.options, ["低电平", "高电平"]);
|
||||
const IIS_MODE_OPTIONS = optionLabels(dacText.IISMode?.options, ["模式1", "模式2", "模式3", "模式4", "模式5", "模式6", "模式7", "模式8"]);
|
||||
|
||||
const goSelect = (title: string, options: string[], selected: number, key: string) => {
|
||||
navigateToSelect(title, options, selected, "/audio", key);
|
||||
@@ -277,6 +309,30 @@ export default function AudioPage() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-white/[0.06]" />
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[16px] text-white">{dacText.analogInputGain ?? "模拟输入增益"}</span>
|
||||
<span className="text-[16px] font-semibold" style={{ color: "#00FFF6" }}>
|
||||
{localAnalogGain >= 0 ? `+${localAnalogGain.toFixed(1)}` : localAnalogGain.toFixed(1)} dB
|
||||
</span>
|
||||
</div>
|
||||
<CyanSlider
|
||||
value={localAnalogGain}
|
||||
min={-12}
|
||||
max={12}
|
||||
step={0.5}
|
||||
onChange={(v) => {
|
||||
isAnalogGainDragging.current = true;
|
||||
setLocalAnalogGain(v);
|
||||
}}
|
||||
onRelease={(v) => {
|
||||
isAnalogGainDragging.current = false;
|
||||
setLocalAnalogGain(v);
|
||||
updateSetting({ analogGain: Math.round(v * 2) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 输出特性 ── */}
|
||||
@@ -292,6 +348,36 @@ export default function AudioPage() {
|
||||
value={GAIN_OPTIONS[gainIdx] ?? GAIN_OPTIONS[0] ?? "—"}
|
||||
onClick={() => goSelect(dacText.dacGain?.label ?? "选择耳机增益", GAIN_OPTIONS, gainIdx, "dacGain")}
|
||||
/>
|
||||
<ListRow
|
||||
label={dacText.autoImpedanceDetection ?? "自动检测耳机阻抗"}
|
||||
toggle
|
||||
checked={autoImpedanceOn}
|
||||
onToggle={(v) => updateSetting({ dacImpedance: v ? 1 : 0 })}
|
||||
/>
|
||||
<ListRow
|
||||
label={dacText.dacVolumeDirect?.label ?? "前级输出音量直通模式"}
|
||||
value={DAC_VOLUME_DIRECT_OPTIONS[dacVolumeDirectIdx] ?? DAC_VOLUME_DIRECT_OPTIONS[0] ?? "—"}
|
||||
onClick={() =>
|
||||
goSelect(
|
||||
dacText.dacVolumeDirect?.label ?? "前级输出音量直通模式",
|
||||
DAC_VOLUME_DIRECT_OPTIONS,
|
||||
dacVolumeDirectIdx,
|
||||
"dacVolumeDirect",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ListRow
|
||||
label={dacText.dacArc?.label ?? "ARC模式"}
|
||||
value={DAC_ARC_OPTIONS[dacArcIdx] ?? DAC_ARC_OPTIONS[0] ?? "—"}
|
||||
onClick={() =>
|
||||
goSelect(
|
||||
dacText.dacArc?.label ?? "ARC模式",
|
||||
DAC_ARC_OPTIONS,
|
||||
dacArcIdx,
|
||||
"dacArc",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ListRow
|
||||
label={dacText.volumeStep ?? "音量幅度"}
|
||||
value={STEP_OPTIONS[stepIdx] ?? STEP_OPTIONS[0]}
|
||||
@@ -299,21 +385,6 @@ export default function AudioPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── IIS 设置 ── */}
|
||||
<SectionHeader title={dacText.sectionIisSettings ?? "IIS设置"} />
|
||||
<div className="ios-list-group">
|
||||
<ListRow
|
||||
label={dacText.mutePolar?.label ?? "IIS 静音电平"}
|
||||
value={MUTE_POLAR_OPTIONS[mutePolarIdx] ?? MUTE_POLAR_OPTIONS[0] ?? "—"}
|
||||
onClick={() => goSelect(dacText.mutePolar?.label ?? "选择 IIS 静音电平", MUTE_POLAR_OPTIONS, mutePolarIdx, "mutePolar")}
|
||||
/>
|
||||
<ListRow
|
||||
label={dacText.IISMode?.label ?? "IIS 模式"}
|
||||
value={IIS_MODE_OPTIONS[iisModeIdx] ?? IIS_MODE_OPTIONS[0] ?? "—"}
|
||||
onClick={() => goSelect(dacText.IISMode?.label ?? "选择 IIS 模式", IIS_MODE_OPTIONS, iisModeIdx, "IISMode")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 系统与接口 ── */}
|
||||
<SectionHeader title={dacText.sectionSystemInterface ?? "系统与接口"} />
|
||||
<div className="ios-list-group">
|
||||
|
||||
@@ -11,8 +11,10 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
import SubwooferLpfChart from "@/components/SubwooferLpfChart";
|
||||
import { FeatureGate } from "@/components/FeatureGate";
|
||||
import { navigateToSelect } from "./SelectPage";
|
||||
import { parseFirmwareVersion } from "@/lib/luxsinApi";
|
||||
import localeZh from "@/locales/data-zh.json";
|
||||
import localeZhHK from "@/locales/data-zh-HK.json";
|
||||
import localeEn from "@/locales/data-en.json";
|
||||
@@ -24,6 +26,115 @@ function clampPickIndex(i: number, len: number) {
|
||||
return Math.min(Math.max(i, 0), len - 1);
|
||||
}
|
||||
|
||||
const SUBWOOFER_DELAY_MS_DIVISOR = 48;
|
||||
const SUBWOOFER_DELAY_CM_NUMERATOR = 34;
|
||||
const SUBWOOFER_DELAY_MAX = 1920;
|
||||
|
||||
function clampSubwooferDelayValue(v: number) {
|
||||
return Math.round(Math.min(SUBWOOFER_DELAY_MAX, Math.max(0, v)));
|
||||
}
|
||||
|
||||
function subwooferDelayToMs(value: number) {
|
||||
return value / SUBWOOFER_DELAY_MS_DIVISOR;
|
||||
}
|
||||
|
||||
function subwooferDelayToCm(value: number) {
|
||||
return (value * SUBWOOFER_DELAY_CM_NUMERATOR) / SUBWOOFER_DELAY_MS_DIVISOR;
|
||||
}
|
||||
|
||||
function formatSubwooferDelayValue(value: number) {
|
||||
const ms = subwooferDelayToMs(value);
|
||||
const cm = subwooferDelayToCm(value);
|
||||
return `${ms.toFixed(2)}ms(${cm.toFixed(1)}cm)`;
|
||||
}
|
||||
|
||||
type DelayChannel = "L" | "R";
|
||||
|
||||
function DelaySpeakerIcon({ direction }: { direction: "left" | "right" }) {
|
||||
return (
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={direction === "left" ? "scale-x-[-1]" : undefined}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M11 5 6 9H3v6h3l5 4V5z" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FullRangeToggleButton({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-lg border-2 px-4 py-2.5 text-[13px] font-medium whitespace-nowrap transition-all active:scale-[0.98] ${
|
||||
active
|
||||
? "border-[#00FFF6] bg-[#00FFF6] text-black shadow-[0_0_10px_rgba(0,255,246,0.35)]"
|
||||
: "border-[#00FFF6]/45 bg-transparent text-white/75"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DelayChannelToggle({
|
||||
channel,
|
||||
onChange,
|
||||
leftLabel,
|
||||
rightLabel,
|
||||
}: {
|
||||
channel: DelayChannel;
|
||||
onChange: (ch: DelayChannel) => void;
|
||||
leftLabel: string;
|
||||
rightLabel: string;
|
||||
}) {
|
||||
const btnClass = (active: boolean) =>
|
||||
`flex items-center justify-center w-10 h-10 rounded-lg transition-colors ${
|
||||
active ? "bg-[#00FFF6]/20 text-[#00FFF6]" : "text-white/35 active:bg-white/10"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={btnClass(channel === "L")}
|
||||
onClick={() => onChange("L")}
|
||||
aria-label={leftLabel}
|
||||
aria-pressed={channel === "L"}
|
||||
>
|
||||
<DelaySpeakerIcon direction="left" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btnClass(channel === "R")}
|
||||
onClick={() => onChange("R")}
|
||||
aria-label={rightLabel}
|
||||
aria-pressed={channel === "R"}
|
||||
>
|
||||
<DelaySpeakerIcon direction="right" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="ios-toggle" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -103,6 +214,33 @@ export default function EffectsPage() {
|
||||
const loudnessThreshold = ds?.loudness_threshold_gain ?? 0;
|
||||
const loudnessBassGain = (ds?.loudness_bass_gain ?? 0) / 10;
|
||||
const loudnessTrebleGain = (ds?.loudness_treble_gain ?? 0) / 10;
|
||||
const subwooferOn = (ds?.subwoofer_enable ?? 0) === 1;
|
||||
const subwooferFreq = Math.min(300, Math.max(40, ds?.subwoofer_value ?? 80));
|
||||
const subwooferRate = Math.min(7, Math.max(0, ds?.subwoofer_rate ?? 0));
|
||||
const subwooferGain = Math.min(15, Math.max(-15, ds?.subwoofer_gain ?? 0));
|
||||
const subwooferMixType = ds?.subwoofer_mix_type ?? 0;
|
||||
const subwooferLpfOn = (ds?.subwoofer_lpf_enable ?? 0) === 1;
|
||||
const subwooferHpfOn = (ds?.subwoofer_hpf_enable ?? 0) === 1;
|
||||
const firmwareVersion = parseFirmwareVersion(ds?.version);
|
||||
const showSubwooferFullRange = firmwareVersion >= 2001;
|
||||
|
||||
const subwooferDelayMainL = clampSubwooferDelayValue(ds?.subwoofer_delay_main ?? 0);
|
||||
const subwooferDelaySubL = clampSubwooferDelayValue(ds?.subwoofer_delay ?? 0);
|
||||
const subwooferDelayMainR = clampSubwooferDelayValue(ds?.subwoofer_delay_main_r ?? 0);
|
||||
const subwooferDelaySubR = clampSubwooferDelayValue(ds?.subwoofer_delay_r ?? 0);
|
||||
const subwooferText = effectText.subwoofer;
|
||||
const subwooferDelayText = subwooferText?.delay;
|
||||
const subwooferRateLabels = useMemo(
|
||||
() => subwooferText?.rateOptions ?? [
|
||||
"6dB/oct", "12dB/oct", "18dB/oct", "24dB/oct",
|
||||
"30dB/oct", "36dB/oct", "42dB/oct", "48dB/oct",
|
||||
],
|
||||
[subwooferText],
|
||||
);
|
||||
const subwooferOutputLabels = useMemo(() => {
|
||||
const opts = subwooferText?.output?.options ?? [];
|
||||
return [...opts].sort((a, b) => a.index - b.index).map((o) => o.label);
|
||||
}, [subwooferText]);
|
||||
|
||||
const [localBass, setLocalBass] = useState(colorBassGain);
|
||||
const [localMid, setLocalMid] = useState(colorMidGain);
|
||||
@@ -130,10 +268,37 @@ export default function EffectsPage() {
|
||||
const [localWidth, setLocalWidth] = useState(() => Math.round(widthVal));
|
||||
const isDragging = useRef(false);
|
||||
|
||||
const [delayChannel, setDelayChannel] = useState<DelayChannel>("L");
|
||||
|
||||
const subwooferDelayMain =
|
||||
delayChannel === "L" ? subwooferDelayMainL : subwooferDelayMainR;
|
||||
const subwooferDelaySub =
|
||||
delayChannel === "L" ? subwooferDelaySubL : subwooferDelaySubR;
|
||||
const subwooferDelayMainField =
|
||||
delayChannel === "L" ? "subwoofer_delay_main" : "subwoofer_delay_main_r";
|
||||
const subwooferDelaySubField =
|
||||
delayChannel === "L" ? "subwoofer_delay" : "subwoofer_delay_r";
|
||||
|
||||
const [localSubFreq, setLocalSubFreq] = useState(subwooferFreq);
|
||||
const [localSubGain, setLocalSubGain] = useState(subwooferGain);
|
||||
const [localSubDelayMain, setLocalSubDelayMain] = useState(subwooferDelayMain);
|
||||
const [localSubDelaySub, setLocalSubDelaySub] = useState(subwooferDelaySub);
|
||||
const isDraggingSubFreq = useRef(false);
|
||||
const isDraggingSubGain = useRef(false);
|
||||
const isDraggingSubDelayMain = useRef(false);
|
||||
const isDraggingSubDelaySub = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging.current) setLocalWidth(Math.round(widthVal));
|
||||
}, [widthVal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingSubFreq.current) setLocalSubFreq(subwooferFreq);
|
||||
if (!isDraggingSubGain.current) setLocalSubGain(subwooferGain);
|
||||
if (!isDraggingSubDelayMain.current) setLocalSubDelayMain(subwooferDelayMain);
|
||||
if (!isDraggingSubDelaySub.current) setLocalSubDelaySub(subwooferDelaySub);
|
||||
}, [subwooferFreq, subwooferGain, subwooferDelayMain, subwooferDelaySub]);
|
||||
|
||||
const goSelect = (title: string, options: string[], selected: number, key: string) => {
|
||||
navigateToSelect(title, options, selected, "/effects", key);
|
||||
setLocation("/select");
|
||||
@@ -143,6 +308,10 @@ export default function EffectsPage() {
|
||||
const sceneDisplay = sceneLabels[sceneIdx] ?? "";
|
||||
const crossIdx = clampPickIndex(crossfeedVal, crossfeedLabels.length);
|
||||
const crossfeedDisplay = crossfeedLabels[crossIdx] ?? "";
|
||||
const subRateIdx = clampPickIndex(subwooferRate, subwooferRateLabels.length);
|
||||
const subRateDisplay = subwooferRateLabels[subRateIdx] ?? "";
|
||||
const subMixIdx = clampPickIndex(subwooferMixType, subwooferOutputLabels.length);
|
||||
const subMixDisplay = subwooferOutputLabels[subMixIdx] ?? "";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black">
|
||||
@@ -389,6 +558,203 @@ export default function EffectsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 低音炮输出 ── */}
|
||||
<div className="ios-list-group p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[16px] font-medium text-white">{subwooferText?.label ?? "低音炮输出"}</span>
|
||||
<IOSToggle checked={subwooferOn} onChange={(v) => updateSetting({ subwoofer_enable: v ? 1 : 0 })} />
|
||||
</div>
|
||||
|
||||
{subwooferOn && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div>
|
||||
<SubwooferLpfChart
|
||||
cutoffHz={localSubFreq}
|
||||
rateIndex={subwooferRate}
|
||||
className="w-full h-[min(42vw,200px)] min-h-[140px] md:h-[260px] lg:h-[300px] mb-3 rounded-lg bg-black/30"
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[14px] font-medium text-white/80">
|
||||
{subwooferText?.cutOffFrequency ?? "截止频率"}
|
||||
</span>
|
||||
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>{localSubFreq}Hz</span>
|
||||
</div>
|
||||
<CyanSlider
|
||||
value={localSubFreq}
|
||||
min={40}
|
||||
max={300}
|
||||
step={1}
|
||||
onChange={(v) => {
|
||||
isDraggingSubFreq.current = true;
|
||||
setLocalSubFreq(Math.round(v));
|
||||
}}
|
||||
onRelease={(v) => {
|
||||
isDraggingSubFreq.current = false;
|
||||
const freq = Math.round(Math.min(300, Math.max(40, v)));
|
||||
setLocalSubFreq(freq);
|
||||
updateSetting({ subwoofer_value: freq });
|
||||
}}
|
||||
/>
|
||||
{showSubwooferFullRange && (
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-3 w-full">
|
||||
<FullRangeToggleButton
|
||||
label={subwooferText?.subFullRange ?? "低音炮全频"}
|
||||
active={subwooferLpfOn}
|
||||
onClick={() =>
|
||||
updateSetting({ subwoofer_lpf_enable: subwooferLpfOn ? 0 : 1 })
|
||||
}
|
||||
/>
|
||||
<FullRangeToggleButton
|
||||
label={subwooferText?.mainFullRange ?? "主音箱全频"}
|
||||
active={subwooferHpfOn}
|
||||
onClick={() =>
|
||||
updateSetting({ subwoofer_hpf_enable: subwooferHpfOn ? 0 : 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[14px] font-medium text-white/80">
|
||||
{subwooferText?.attenuationSlope ?? "衰减斜率"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center justify-between w-full active:opacity-70 transition-opacity"
|
||||
onClick={() =>
|
||||
goSelect(
|
||||
subwooferText?.selectAttenuationTitle ?? "选择衰减斜率",
|
||||
subwooferRateLabels,
|
||||
subwooferRate,
|
||||
"subwoofer_rate",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="text-[14px] text-white/45">{subRateDisplay}</span>
|
||||
<ChevronRight size={16} className="ios-chevron" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[14px] font-medium text-white/80">{subwooferText?.gain ?? "增益"}</span>
|
||||
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>
|
||||
{localSubGain >= 0 ? `+${localSubGain}` : localSubGain} dB
|
||||
</span>
|
||||
</div>
|
||||
<CyanSlider
|
||||
value={localSubGain}
|
||||
min={-15}
|
||||
max={15}
|
||||
step={1}
|
||||
onChange={(v) => {
|
||||
isDraggingSubGain.current = true;
|
||||
setLocalSubGain(Math.round(v));
|
||||
}}
|
||||
onRelease={(v) => {
|
||||
isDraggingSubGain.current = false;
|
||||
const gain = Math.round(Math.min(15, Math.max(-15, v)));
|
||||
setLocalSubGain(gain);
|
||||
updateSetting({ subwoofer_gain: gain });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[14px] font-medium text-white/80">
|
||||
{subwooferText?.output?.label ?? "输出方式"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center justify-between w-full active:opacity-70 transition-opacity"
|
||||
onClick={() =>
|
||||
goSelect(
|
||||
subwooferText?.selectOutputTitle ?? "选择输出方式",
|
||||
subwooferOutputLabels,
|
||||
subwooferMixType,
|
||||
"subwoofer_mix_type",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="text-[14px] text-white/45">{subMixDisplay}</span>
|
||||
<ChevronRight size={16} className="ios-chevron" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/[0.06] pt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[14px] font-medium text-white/80">
|
||||
{subwooferDelayText?.label ?? "延时"}
|
||||
</span>
|
||||
<DelayChannelToggle
|
||||
channel={delayChannel}
|
||||
onChange={setDelayChannel}
|
||||
leftLabel={subwooferDelayText?.leftChannel ?? "左声道"}
|
||||
rightLabel={subwooferDelayText?.rightChannel ?? "右声道"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[14px] text-white/60">
|
||||
{subwooferDelayText?.mainSpeaker ?? "主机箱"}
|
||||
</span>
|
||||
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>
|
||||
{formatSubwooferDelayValue(localSubDelayMain)}
|
||||
</span>
|
||||
</div>
|
||||
<CyanSlider
|
||||
value={localSubDelayMain}
|
||||
min={0}
|
||||
max={SUBWOOFER_DELAY_MAX}
|
||||
step={1}
|
||||
onChange={(v) => {
|
||||
isDraggingSubDelayMain.current = true;
|
||||
setLocalSubDelayMain(clampSubwooferDelayValue(v));
|
||||
}}
|
||||
onRelease={(v) => {
|
||||
isDraggingSubDelayMain.current = false;
|
||||
const delayVal = clampSubwooferDelayValue(v);
|
||||
setLocalSubDelayMain(delayVal);
|
||||
updateSetting({ [subwooferDelayMainField]: delayVal });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[14px] text-white/60">
|
||||
{subwooferDelayText?.subwoofer ?? "低音炮"}
|
||||
</span>
|
||||
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>
|
||||
{formatSubwooferDelayValue(localSubDelaySub)}
|
||||
</span>
|
||||
</div>
|
||||
<CyanSlider
|
||||
value={localSubDelaySub}
|
||||
min={0}
|
||||
max={SUBWOOFER_DELAY_MAX}
|
||||
step={1}
|
||||
onChange={(v) => {
|
||||
isDraggingSubDelaySub.current = true;
|
||||
setLocalSubDelaySub(clampSubwooferDelayValue(v));
|
||||
}}
|
||||
onRelease={(v) => {
|
||||
isDraggingSubDelaySub.current = false;
|
||||
const delayVal = clampSubwooferDelayValue(v);
|
||||
setLocalSubDelaySub(delayVal);
|
||||
updateSetting({ [subwooferDelaySubField]: delayVal });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FeatureGate>
|
||||
</div>
|
||||
|
||||
|
||||
+100
-22
@@ -22,7 +22,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { INPUT_LABELS, OUTPUT_LABELS } from "@/lib/luxsinApi";
|
||||
import { INPUT_LABELS, isHomeVolumeLockedByPassthrough, OUTPUT_LABELS } from "@/lib/luxsinApi";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronRight,
|
||||
@@ -378,6 +378,38 @@ export default function Home() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const volumePassthroughActive = isHomeVolumeLockedByPassthrough(
|
||||
deviceState?.dacVolumeDirect,
|
||||
deviceState?.output,
|
||||
);
|
||||
const volumePassthroughModeLabel =
|
||||
deviceState?.dacVolumeDirect === 1
|
||||
? (homeText.volumePassthroughMode0dB ?? "0dB")
|
||||
: deviceState?.dacVolumeDirect === 2
|
||||
? (homeText.volumePassthroughMode12dB ?? "-12dB")
|
||||
: "";
|
||||
|
||||
const notifyVolumePassthroughBlocked = useCallback(() => {
|
||||
const template =
|
||||
homeText.volumePassthroughBlocked ??
|
||||
"当前为音量直通模式({mode}),无法调节耳机音量。请在音频设置中关闭直通或改为「关闭」。";
|
||||
toast.info(template.replace("{mode}", volumePassthroughModeLabel));
|
||||
}, [homeText, volumePassthroughModeLabel]);
|
||||
|
||||
const blockVolumeAdjust = useCallback(() => {
|
||||
if (!volumePassthroughActive) return false;
|
||||
notifyVolumePassthroughBlocked();
|
||||
return true;
|
||||
}, [volumePassthroughActive, notifyVolumePassthroughBlocked]);
|
||||
|
||||
const applyVolume = useCallback(
|
||||
(v: number) => {
|
||||
if (blockVolumeAdjust()) return;
|
||||
setVolume(v);
|
||||
},
|
||||
[blockVolumeAdjust, setVolume],
|
||||
);
|
||||
|
||||
if (!isConnected) return <ConnectionPlaceholder />;
|
||||
|
||||
const ds = deviceState!;
|
||||
@@ -413,6 +445,7 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const beginKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (blockVolumeAdjust()) return;
|
||||
const el = e.currentTarget;
|
||||
const rect = el.getBoundingClientRect();
|
||||
knobDraggingRef.current = true;
|
||||
@@ -442,7 +475,7 @@ export default function Home() {
|
||||
knobDraggingRef.current = false;
|
||||
knobActivePointerIdRef.current = null;
|
||||
isDragging.current = false;
|
||||
setVolume(knobLastVolRef.current);
|
||||
applyVolume(knobLastVolRef.current);
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
@@ -549,7 +582,7 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
{/* Right knob */}
|
||||
<div className="pr-4">
|
||||
<div className={cn("pr-4", volumePassthroughActive && "opacity-40 pointer-events-none")}>
|
||||
<div
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center touch-none select-none"
|
||||
style={{
|
||||
@@ -564,6 +597,7 @@ export default function Home() {
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={200}
|
||||
aria-valuenow={localVol}
|
||||
aria-disabled={volumePassthroughActive}
|
||||
onPointerDown={beginKnobDrag}
|
||||
onPointerMove={moveKnobDrag}
|
||||
onPointerUp={endKnobDrag}
|
||||
@@ -583,12 +617,36 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* ── Volume slider ── */}
|
||||
<div className="px-4 mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn("px-4 mb-5", volumePassthroughActive && "opacity-45")}
|
||||
onPointerDownCapture={(e) => {
|
||||
if (volumePassthroughActive) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
notifyVolumePassthroughBlocked();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{volumePassthroughActive && (
|
||||
<p className="text-center text-[13px] mb-3 px-2" style={{ color: "rgba(0,255,246,0.85)" }}>
|
||||
{homeText.volumePassthroughHint ?? "当前为音量直通模式,音量不可调节"}
|
||||
{volumePassthroughModeLabel ? ` (${volumePassthroughModeLabel})` : ""}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3",
|
||||
volumePassthroughActive && "pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVolumeConfirm("min")}
|
||||
className="text-white/50 active:text-white transition-colors flex-shrink-0"
|
||||
disabled={volumePassthroughActive}
|
||||
onClick={() => {
|
||||
if (blockVolumeAdjust()) return;
|
||||
setVolumeConfirm("min");
|
||||
}}
|
||||
className="text-white/50 active:text-white transition-colors flex-shrink-0 disabled:opacity-40"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-6 h-6">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||
@@ -606,25 +664,28 @@ export default function Home() {
|
||||
}} />
|
||||
<input
|
||||
type="range" min={0} max={200} value={localVol}
|
||||
className="cyan-slider relative z-10"
|
||||
disabled={volumePassthroughActive}
|
||||
className="cyan-slider relative z-10 disabled:opacity-50"
|
||||
onChange={(e) => {
|
||||
if (volumePassthroughActive) return;
|
||||
const v = Number(e.target.value);
|
||||
setLocalVol(v);
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
if (volumePassthroughActive) return;
|
||||
isDragging.current = true;
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
isDragging.current = false;
|
||||
setVolume(Number(e.currentTarget.value));
|
||||
applyVolume(Number(e.currentTarget.value));
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
isDragging.current = false;
|
||||
setVolume(Number(e.currentTarget.value));
|
||||
applyVolume(Number(e.currentTarget.value));
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
isDragging.current = false;
|
||||
setVolume(Number(e.currentTarget.value));
|
||||
applyVolume(Number(e.currentTarget.value));
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
const k = e.key;
|
||||
@@ -638,7 +699,7 @@ export default function Home() {
|
||||
k === "PageUp" ||
|
||||
k === "PageDown"
|
||||
) {
|
||||
setVolume(Number(e.currentTarget.value));
|
||||
applyVolume(Number(e.currentTarget.value));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -646,8 +707,12 @@ export default function Home() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVolumeConfirm("max")}
|
||||
className="text-white/50 active:text-white transition-colors flex-shrink-0"
|
||||
disabled={volumePassthroughActive}
|
||||
onClick={() => {
|
||||
if (blockVolumeAdjust()) return;
|
||||
setVolumeConfirm("max");
|
||||
}}
|
||||
className="text-white/50 active:text-white transition-colors flex-shrink-0 disabled:opacity-40"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-6 h-6">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||
@@ -657,11 +722,16 @@ export default function Home() {
|
||||
</button>
|
||||
</div>
|
||||
{/* dB value */}
|
||||
<div className="mt-[6px] flex items-center justify-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"mt-[6px] flex items-center justify-center gap-3",
|
||||
volumePassthroughActive && "pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Decrease volume"
|
||||
disabled={localVol <= 0}
|
||||
disabled={volumePassthroughActive || localVol <= 0}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: "rgba(44,44,46,0.6)",
|
||||
@@ -669,22 +739,29 @@ export default function Home() {
|
||||
color: "rgba(255,255,255,0.55)",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (blockVolumeAdjust()) return;
|
||||
const next = Math.max(0, Math.min(200, localVol - fineVolumeStep));
|
||||
setLocalVol(next);
|
||||
setVolume(next);
|
||||
applyVolume(next);
|
||||
}}
|
||||
>
|
||||
<span className="text-[20px] leading-none">-</span>
|
||||
</button>
|
||||
|
||||
<span className="text-[22px] font-bold tracking-tight" style={{ color: "#00FFF6" }}>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[22px] font-bold tracking-tight",
|
||||
volumePassthroughActive ? "text-white/35" : "",
|
||||
)}
|
||||
style={volumePassthroughActive ? undefined : { color: "#00FFF6" }}
|
||||
>
|
||||
{volToDB(localVol)}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Increase volume"
|
||||
disabled={localVol >= 200}
|
||||
disabled={volumePassthroughActive || localVol >= 200}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: "rgba(44,44,46,0.6)",
|
||||
@@ -692,9 +769,10 @@ export default function Home() {
|
||||
color: "rgba(255,255,255,0.55)",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (blockVolumeAdjust()) return;
|
||||
const next = Math.max(0, Math.min(200, localVol + fineVolumeStep));
|
||||
setLocalVol(next);
|
||||
setVolume(next);
|
||||
applyVolume(next);
|
||||
}}
|
||||
>
|
||||
<span className="text-[20px] leading-none">+</span>
|
||||
@@ -877,10 +955,10 @@ export default function Home() {
|
||||
onClick={() => {
|
||||
if (volumeConfirm === "min") {
|
||||
setLocalVol(0);
|
||||
setVolume(0);
|
||||
applyVolume(0);
|
||||
} else if (volumeConfirm === "max") {
|
||||
setLocalVol(200);
|
||||
setVolume(200);
|
||||
applyVolume(200);
|
||||
}
|
||||
setVolumeConfirm(null);
|
||||
}}
|
||||
|
||||
+429
-26
@@ -1,190 +1,593 @@
|
||||
/* ============================================================
|
||||
|
||||
SELECT PAGE — Universal full-screen option picker
|
||||
|
||||
Design: iOS Settings style selection list
|
||||
|
||||
Usage: Navigate to /select with state object
|
||||
|
||||
|
||||
|
||||
State params:
|
||||
|
||||
title - Page title shown in header
|
||||
|
||||
options - Array of option strings
|
||||
|
||||
selected - Currently selected index (number)
|
||||
|
||||
back - Route to navigate back to after selection
|
||||
|
||||
key - Identifier for which setting this controls
|
||||
|
||||
============================================================ */
|
||||
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
import { ChevronLeft, Check } from "lucide-react";
|
||||
|
||||
import { useDevice } from "@/contexts/DeviceContext";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
|
||||
AlertDialog,
|
||||
|
||||
AlertDialogAction,
|
||||
|
||||
AlertDialogCancel,
|
||||
|
||||
AlertDialogContent,
|
||||
|
||||
AlertDialogDescription,
|
||||
|
||||
AlertDialogFooter,
|
||||
|
||||
AlertDialogHeader,
|
||||
|
||||
AlertDialogTitle,
|
||||
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
import localeZh from "@/locales/data-zh.json";
|
||||
|
||||
import localeZhHK from "@/locales/data-zh-HK.json";
|
||||
|
||||
import localeEn from "@/locales/data-en.json";
|
||||
|
||||
|
||||
|
||||
const iisModeImageUrl = (index: number) =>
|
||||
|
||||
`${import.meta.env.BASE_URL}IIS/iis_app${index + 1}.png`;
|
||||
|
||||
/* ── Key → API field mapping ── */
|
||||
const KEY_TO_SETTING: Record<string, string> = {
|
||||
effect_value: "effect_value",
|
||||
crossfeed_value: "crossfeed_value",
|
||||
language: "language",
|
||||
analogGain: "analogGain",
|
||||
soundStep: "soundStep",
|
||||
bootSound: "bootSound",
|
||||
filterCharacteristic: "pcm",
|
||||
mutePolar: "hdmimutepolar",
|
||||
IISMode: "hdmiType",
|
||||
xlr: "xlr",
|
||||
dacGain: "dacGain",
|
||||
dacImpedance: "dacImpedance",
|
||||
vu: "vu",
|
||||
input: "input",
|
||||
output: "output",
|
||||
screenLight: "screenLight",
|
||||
screenOff: "screenOff",
|
||||
buttonLight: "buttonLight",
|
||||
autoHome: "autoHome",
|
||||
sleep: "sleep",
|
||||
headphone: "peqSelect",
|
||||
|
||||
|
||||
type DacVolumeDirectLocale = {
|
||||
|
||||
label?: string;
|
||||
|
||||
confirmTitle?: string;
|
||||
|
||||
confirm0dB?: string;
|
||||
|
||||
confirm12dB?: string;
|
||||
|
||||
confirmOk?: string;
|
||||
|
||||
confirmCancel?: string;
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
type LocaleRoot = {
|
||||
|
||||
dac?: { dacVolumeDirect?: DacVolumeDirectLocale };
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const LOCALE_BY_LANG: Record<number, LocaleRoot> = {
|
||||
|
||||
0: localeEn as LocaleRoot,
|
||||
|
||||
1: localeZhHK as LocaleRoot,
|
||||
|
||||
2: localeZh as LocaleRoot,
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
/* ── Key → API field mapping ── */
|
||||
|
||||
const KEY_TO_SETTING: Record<string, string> = {
|
||||
|
||||
effect_value: "effect_value",
|
||||
|
||||
crossfeed_value: "crossfeed_value",
|
||||
|
||||
subwoofer_rate: "subwoofer_rate",
|
||||
|
||||
subwoofer_mix_type: "subwoofer_mix_type",
|
||||
|
||||
language: "language",
|
||||
|
||||
analogGain: "analogGain",
|
||||
|
||||
soundStep: "soundStep",
|
||||
|
||||
bootSound: "bootSound",
|
||||
|
||||
filterCharacteristic: "pcm",
|
||||
|
||||
mutePolar: "hdmimutepolar",
|
||||
|
||||
IISMode: "hdmiType",
|
||||
|
||||
xlr: "xlr",
|
||||
|
||||
dacGain: "dacGain",
|
||||
|
||||
dacImpedance: "dacImpedance",
|
||||
|
||||
dacVolumeDirect: "dacVolumeDirect",
|
||||
|
||||
dacArc: "dacArc",
|
||||
|
||||
vu: "vu",
|
||||
|
||||
input: "input",
|
||||
|
||||
output: "output",
|
||||
|
||||
screenLight: "screenLight",
|
||||
|
||||
screenOff: "screenOff",
|
||||
|
||||
buttonLight: "buttonLight",
|
||||
|
||||
autoHome: "autoHome",
|
||||
|
||||
sleep: "sleep",
|
||||
|
||||
headphone: "peqSelect",
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Global state for select page (to avoid URL params)
|
||||
|
||||
export let selectPageState: {
|
||||
|
||||
title: string;
|
||||
|
||||
options: string[];
|
||||
|
||||
selected: number;
|
||||
|
||||
back: string;
|
||||
|
||||
key: string;
|
||||
|
||||
} | null = null;
|
||||
|
||||
|
||||
|
||||
// Last select result for pages that need local state update
|
||||
|
||||
export let selectPageResult: { key: string; selected: number } | null = null;
|
||||
|
||||
|
||||
|
||||
export function consumeSelectPageResult() {
|
||||
|
||||
const result = selectPageResult;
|
||||
|
||||
selectPageResult = null;
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Helper function to set select page state
|
||||
|
||||
export function navigateToSelect(title: string, options: string[], selected: number, back: string, key: string) {
|
||||
|
||||
selectPageState = { title, options, selected, back, key };
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function SelectPage() {
|
||||
|
||||
const [location, setLocation] = useLocation();
|
||||
const { updateSetting } = useDevice();
|
||||
|
||||
const { updateSetting, deviceState } = useDevice();
|
||||
|
||||
const [state, setState] = useState<{
|
||||
|
||||
title: string;
|
||||
|
||||
options: string[];
|
||||
|
||||
selected: number;
|
||||
|
||||
back: string;
|
||||
|
||||
key: string;
|
||||
|
||||
} | null>(() => selectPageState);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [passthroughConfirmIdx, setPassthroughConfirmIdx] = useState<number | null>(null);
|
||||
|
||||
|
||||
|
||||
const langIdx = deviceState?.language ?? 2;
|
||||
|
||||
const dacVolumeDirectText = (LOCALE_BY_LANG[langIdx] ?? localeZh as LocaleRoot).dac?.dacVolumeDirect;
|
||||
|
||||
|
||||
|
||||
const passthroughConfirmCopy = useMemo(() => {
|
||||
|
||||
if (passthroughConfirmIdx === null) return null;
|
||||
|
||||
const message =
|
||||
|
||||
passthroughConfirmIdx === 1
|
||||
|
||||
? dacVolumeDirectText?.confirm0dB
|
||||
|
||||
: passthroughConfirmIdx === 2
|
||||
|
||||
? dacVolumeDirectText?.confirm12dB
|
||||
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
||||
title: dacVolumeDirectText?.confirmTitle ?? "提示",
|
||||
|
||||
message:
|
||||
|
||||
message ??
|
||||
|
||||
(passthroughConfirmIdx === 1
|
||||
|
||||
? "直通模式会将音量调到最大,并且不可调节"
|
||||
|
||||
: "直通模式会将音量调到-12dB,并且不可调节"),
|
||||
|
||||
ok: dacVolumeDirectText?.confirmOk ?? "确认",
|
||||
|
||||
cancel: dacVolumeDirectText?.confirmCancel ?? "取消",
|
||||
|
||||
};
|
||||
|
||||
}, [passthroughConfirmIdx, dacVolumeDirectText]);
|
||||
|
||||
|
||||
|
||||
// Re-read global state whenever we enter /select (avoids stale key/options after remount)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (location === "/select" && selectPageState) {
|
||||
|
||||
setState({ ...selectPageState });
|
||||
|
||||
}
|
||||
|
||||
}, [location]);
|
||||
|
||||
|
||||
|
||||
if (!state) {
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const { title, options, selected: selectedIdx, back, key } = state;
|
||||
|
||||
const handleSelect = async (idx: number) => {
|
||||
|
||||
|
||||
const applySelect = async (idx: number) => {
|
||||
|
||||
if (saving) return;
|
||||
|
||||
selectPageResult = { key, selected: idx };
|
||||
|
||||
|
||||
|
||||
const apiField = KEY_TO_SETTING[key];
|
||||
|
||||
if (apiField) {
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
|
||||
await updateSetting({ [apiField]: idx });
|
||||
|
||||
} finally {
|
||||
|
||||
setSaving(false);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
selectPageState = null;
|
||||
|
||||
setLocation(back);
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleSelect = async (idx: number) => {
|
||||
|
||||
if (saving) return;
|
||||
|
||||
if (key === "dacVolumeDirect" && (idx === 1 || idx === 2) && idx !== selectedIdx) {
|
||||
|
||||
setPassthroughConfirmIdx(idx);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
await applySelect(idx);
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handlePassthroughConfirm = async () => {
|
||||
|
||||
if (passthroughConfirmIdx === null) return;
|
||||
|
||||
const idx = passthroughConfirmIdx;
|
||||
|
||||
setPassthroughConfirmIdx(null);
|
||||
|
||||
await applySelect(idx);
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div className="min-h-screen bg-black">
|
||||
|
||||
{/* ── Header ── */}
|
||||
|
||||
<div className="page-header">
|
||||
|
||||
<button
|
||||
|
||||
onClick={() => setLocation(back)}
|
||||
|
||||
className="mr-4 text-white/60 active:text-white transition-colors">
|
||||
|
||||
<ChevronLeft size={24} />
|
||||
|
||||
</button>
|
||||
|
||||
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">{title}</h1>
|
||||
|
||||
<div className="w-8" />
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="px-4 pt-4 pb-24">
|
||||
|
||||
{key === "IISMode" ? (
|
||||
|
||||
<div className="grid w-full grid-cols-2 gap-3">
|
||||
|
||||
{options.map((opt, idx) => {
|
||||
|
||||
const isSelected = idx === selectedIdx;
|
||||
|
||||
return (
|
||||
|
||||
<button
|
||||
|
||||
key={idx}
|
||||
|
||||
type="button"
|
||||
|
||||
disabled={saving}
|
||||
|
||||
className={cn(
|
||||
|
||||
"flex flex-col items-center gap-2 rounded-xl border-2 p-1.5 transition-all active:scale-[0.98] disabled:opacity-60",
|
||||
|
||||
isSelected
|
||||
|
||||
? "border-[#00FFF6] shadow-[0_0_14px_rgba(0,255,246,0.35)]"
|
||||
|
||||
: "border-white/10",
|
||||
|
||||
)}
|
||||
|
||||
onClick={() => void handleSelect(idx)}
|
||||
|
||||
aria-pressed={isSelected}
|
||||
|
||||
aria-label={opt}
|
||||
|
||||
>
|
||||
|
||||
<img
|
||||
|
||||
src={iisModeImageUrl(idx)}
|
||||
|
||||
alt={opt}
|
||||
|
||||
className="block w-full h-auto rounded-lg bg-black/40"
|
||||
|
||||
loading="lazy"
|
||||
|
||||
/>
|
||||
|
||||
<span
|
||||
|
||||
className={cn(
|
||||
|
||||
"w-6 h-6 rounded-full flex items-center justify-center shrink-0",
|
||||
|
||||
isSelected
|
||||
|
||||
? "bg-[#00FFF6] text-black shadow-[0_0_10px_rgba(0,255,246,0.45)]"
|
||||
|
||||
: "border-2 border-white/20",
|
||||
|
||||
)}
|
||||
|
||||
>
|
||||
|
||||
{isSelected && <Check size={12} strokeWidth={3} />}
|
||||
|
||||
</span>
|
||||
|
||||
</button>
|
||||
|
||||
);
|
||||
|
||||
})}
|
||||
|
||||
</div>
|
||||
|
||||
) : (
|
||||
|
||||
<div className="ios-list-group">
|
||||
|
||||
{options.map((opt, idx) => {
|
||||
|
||||
const isSelected = idx === selectedIdx;
|
||||
|
||||
return (
|
||||
|
||||
<button
|
||||
|
||||
key={idx}
|
||||
|
||||
className="ios-list-row w-full text-left active:bg-white/5 transition-colors"
|
||||
|
||||
onClick={() => void handleSelect(idx)}
|
||||
|
||||
disabled={saving}
|
||||
|
||||
>
|
||||
|
||||
<span className={`flex-1 text-[16px] ${isSelected ? "text-white font-medium" : "text-white/75"}`}>
|
||||
|
||||
{opt}
|
||||
|
||||
</span>
|
||||
|
||||
{isSelected && (
|
||||
|
||||
<Check size={18} style={{ color: "#00FFF6" }} />
|
||||
|
||||
)}
|
||||
|
||||
</button>
|
||||
|
||||
);
|
||||
|
||||
})}
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<AlertDialog
|
||||
|
||||
open={passthroughConfirmIdx !== null}
|
||||
|
||||
onOpenChange={(open) => {
|
||||
|
||||
if (!open) setPassthroughConfirmIdx(null);
|
||||
|
||||
}}
|
||||
|
||||
>
|
||||
|
||||
<AlertDialogContent className="top-[42%] border-white/10 bg-zinc-900 text-white sm:max-w-md">
|
||||
|
||||
<AlertDialogHeader>
|
||||
|
||||
<AlertDialogTitle className="text-white">
|
||||
|
||||
{passthroughConfirmCopy?.title}
|
||||
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription className="text-white/60">
|
||||
|
||||
{passthroughConfirmCopy?.message}
|
||||
|
||||
</AlertDialogDescription>
|
||||
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
|
||||
<AlertDialogCancel className="border-white/20 bg-transparent text-white hover:bg-white/10">
|
||||
|
||||
{passthroughConfirmCopy?.cancel}
|
||||
|
||||
</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction
|
||||
|
||||
className="bg-[#00FFF6] text-black hover:bg-[#00FFF6]/90 focus-visible:ring-[#00FFF6]"
|
||||
|
||||
disabled={saving}
|
||||
|
||||
onClick={() => void handlePassthroughConfirm()}
|
||||
|
||||
>
|
||||
|
||||
{passthroughConfirmCopy?.ok}
|
||||
|
||||
</AlertDialogAction>
|
||||
|
||||
</AlertDialogFooter>
|
||||
|
||||
</AlertDialogContent>
|
||||
|
||||
</AlertDialog>
|
||||
|
||||
</div>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user