diff --git a/client/src/contexts/DeviceContext.tsx b/client/src/contexts/DeviceContext.tsx index c46675c..619cd8d 100644 --- a/client/src/contexts/DeviceContext.tsx +++ b/client/src/contexts/DeviceContext.tsx @@ -1,6 +1,7 @@ import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; import { DeviceState, + getHomeVolumeMax, LuxsinAPI, MOCK_DEVICE_STATE, MOCK_PEQ_STATE, @@ -213,12 +214,16 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) { }, [api, isDemoMode, applyPeqFiltersToState]); // Optimistic setters + const maxVolume = getHomeVolumeMax(deviceState); const setVolume = useCallback((v: number) => { - setDeviceState(prev => prev ? { ...prev, volume: v } : prev); + if (!Number.isFinite(v)) return; + // 指令和乐观状态使用同一上限,防止任何首页入口发送超限音量。 + const volume = Math.max(0, Math.min(maxVolume, Math.round(v))); + setDeviceState(prev => prev ? { ...prev, volume } : prev); if (!isDemoMode && api) { - api.setVolume(v).catch(console.error); + api.setVolume(volume).catch(console.error); } - }, [api, isDemoMode]); + }, [api, isDemoMode, maxVolume]); const setInput = useCallback((v: number) => { setDeviceState(prev => prev ? { ...prev, input: v } : prev); diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index 63d0b5d..4fcd1a0 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -21,6 +21,32 @@ export function volumeToDb(vol: number): number { return (vol - 200) / 2; } +/** 耳机输出(协议索引 2)使用设置上限;其他输出及未返回设置的旧固件保持 0dB。 */ +export function getHomeVolumeMax( + state: Pick | null | undefined, +): number { + if (state?.output !== 2) return 200; + const rawIndex = Number(state.hpMaxVolume ?? 0); + const index = Number.isFinite(rawIndex) + ? Math.max(0, Math.min(10, Math.trunc(rawIndex))) + : 0; + // 每档降低 5dB,即 10 个设备音量单位。 + return dbToVolume(-5 * index); +} + +/** + * 耳机输出(协议索引 2)且用户设置了耳机最大音量(低于 0dB)时,返回该上限的 dB 值(负数); + * 其他输出、未设置(0 档)或旧固件(无 hpMaxVolume)返回 null。 + */ +export function getHeadphoneMaxVolumeDb( + state: Pick | null | undefined, +): number | null { + if (state?.output !== 2) return null; + const rawIndex = Number(state.hpMaxVolume ?? 0); + if (!Number.isFinite(rawIndex) || Math.trunc(rawIndex) <= 0) return null; + return volumeToDb(getHomeVolumeMax(state)); +} + export function decodeCustomBase64(encoded: string): string { let translated = ""; for (let i = 0; i < encoded.length; i++) { diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index cf2affe..0f5145f 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -26,6 +26,7 @@ "volumeMaxConfirmOk": "Set to maximum", "volumePassthroughLockedToast": "Volume is in passthrough mode and cannot be adjusted.", "volumePassthroughLockedHint": "Volume is in passthrough mode and cannot be adjusted.", + "hpMaxVolumeHint": "Headphone max volume is set. Maximum: {db}dB", "powerOffToastDemo": "Demo mode: shutdown command sent.", "powerOffToastSent": "Shutdown command sent.", "doubleClickHint": "Double-click to enter", diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index be47a5a..05e3e50 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -26,6 +26,7 @@ "volumeMaxConfirmOk": "設為最大", "volumePassthroughLockedToast": "目前為音量直通模式,無法調節音量", "volumePassthroughLockedHint": "目前為音量直通模式,無法調節音量", + "hpMaxVolumeHint": "已設定耳機最大音量,最大音量為:{db}dB", "powerOffToastDemo": "演示模式:已傳送關機指令", "powerOffToastSent": "已傳送關機指令", "doubleClickHint": "按兩下進入", diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index 6f6f591..ec2d100 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -26,6 +26,7 @@ "volumeMaxConfirmOk": "设为最大", "volumePassthroughLockedToast": "当前为音量直通模式,无法调节音量", "volumePassthroughLockedHint": "当前为音量直通模式,无法调节音量", + "hpMaxVolumeHint": "已设置耳机最大音量,最大音量为:{db}dB", "powerOffToastDemo": "演示模式:已发送关机指令", "powerOffToastSent": "已发送关机指令", "doubleClickHint": "双击进入", diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index b1ec5cf..3b9e10b 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -23,7 +23,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { INPUT_LABELS, OUTPUT_LABELS } from "@/lib/luxsinApi"; +import { getHeadphoneMaxVolumeDb, getHomeVolumeMax, INPUT_LABELS, OUTPUT_LABELS } from "@/lib/luxsinApi"; import { cn } from "@/lib/utils"; import { ChevronRight, @@ -250,7 +250,10 @@ export default function Home() { return null; }, [volumeConfirm, homeText]); - const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100); + const maxVolume = getHomeVolumeMax(deviceState); + const [volumeDraft, setLocalVol] = useState(deviceState?.volume ?? 100); + // 上限或输出切换时立即约束显示,拖动中也不能保留超限值。 + const localVol = Math.max(0, Math.min(maxVolume, volumeDraft)); const isDragging = useRef(false); const knobDraggingRef = useRef(false); const knobActivePointerIdRef = useRef(null); @@ -259,10 +262,10 @@ export default function Home() { const knobLastVolRef = useRef(localVol); useEffect(() => { - if (!isDragging.current && deviceState?.volume !== undefined) { - setLocalVol(deviceState.volume); - } - }, [deviceState?.volume]); + setLocalVol(current => Math.max(0, Math.min(maxVolume, + !isDragging.current && deviceState?.volume !== undefined ? deviceState.volume : current, + ))); + }, [deviceState?.volume, maxVolume]); useEffect(() => { knobLastVolRef.current = localVol; @@ -444,16 +447,25 @@ export default function Home() { const vuLabel = `${homeText.vu ?? "VU表"}${(ds.vu ?? 0) + 1}`; const bypassOn = (ds.dsp_enable ?? 0) === 0; + // 耳机输出且已设置耳机最大音量时,提示当前上限,避免用户误以为还能加到 0dB。 + const hpMaxVolumeDb = getHeadphoneMaxVolumeDb(ds); + const hpMaxVolumeHint = + hpMaxVolumeDb === null + ? null + : (homeText.hpMaxVolumeHint ?? "已设置耳机最大音量,最大音量为:{db}dB").replace( + "{db}", + String(hpMaxVolumeDb), + ); + // Home 的 volume 取值为 0..200,对应 dB = (v - 200) / 2 // 因此 1 个 volume 单位 = 0.5dB // AudioPage 的 soundStep:0->0.5dB, 1->1dB, 2->2dB, 3->3dB // 换算为 volume 单位:0.5/1/2/3 dB -> 1/2/4/6 const fineVolumeStep = [1, 2, 4, 6][ds.soundStep ?? 1] ?? 1; - // Slider fill % (0-200 → 0-100%) - const fillPct = (localVol / 200) * 100; - // Knob angle (map 0..200 -> -135..+135 degrees) - const knobAngle = -135 + (localVol / 200) * 270; + // 滑条填充和旋钮角度均按当前输出的最大音量映射。 + const fillPct = (localVol / maxVolume) * 100; + const knobAngle = -135 + (localVol / maxVolume) * 270; const pointAngle = (clientX: number, clientY: number, rect: DOMRect) => { const cx = rect.left + rect.width / 2; @@ -488,9 +500,9 @@ export default function Home() { const rect = el.getBoundingClientRect(); const a = pointAngle(e.clientX, e.clientY, rect); const delta = normalizeDelta(a - knobStartAngleRef.current); - // 270deg travel -> full scale; 1 rad maps to ~200/(1.5*pi) volume steps - const stepsPerRad = 200 / (Math.PI * 1.5); - const next = Math.max(0, Math.min(200, Math.round(knobStartVolRef.current + delta * stepsPerRad))); + // 270 度行程对应当前允许的完整音量范围。 + const stepsPerRad = maxVolume / (Math.PI * 1.5); + const next = Math.max(0, Math.min(maxVolume, Math.round(knobStartVolRef.current + delta * stepsPerRad))); setLocalVol(next); knobLastVolRef.current = next; }; @@ -677,7 +689,7 @@ export default function Home() { /> { @@ -728,7 +740,7 @@ export default function Home() { }} onClick={() => { guardVolumeChange(() => { - const next = Math.max(0, Math.min(200, localVol - fineVolumeStep)); + const next = Math.max(0, Math.min(maxVolume, localVol - fineVolumeStep)); setLocalVol(next); setVolume(next); }); @@ -744,7 +756,7 @@ export default function Home() { + {hpMaxVolumeHint && ( +

+ {hpMaxVolumeHint} +

+ )} + {/* Bluetooth info */} {ds.bt_status !== 0 && (
@@ -937,8 +959,8 @@ export default function Home() { setLocalVol(0); setVolume(0); } else if (volumeConfirm === "max") { - setLocalVol(200); - setVolume(200); + setLocalVol(maxVolume); + setVolume(maxVolume); } setVolumeConfirm(null); }}