feat(device): 增加耳机最大音量限制及显示提示

- 新增 getHomeVolumeMax 函数,计算耳机输出的最大允许音量
- 新增 getHeadphoneMaxVolumeDb 函数,获取耳机最大音量的 dB 值用于提示显示
- 修改 setVolume 函数,限制音量在动态最大值范围内,防止首页入口发送超限音量
- Home 页面音量滑块、旋钮角度及音量范围由固定 200 调整为当前设备最大音量
- 添加耳机最大音量提示文本,根据用户设置动态显示最大 dB 值
- 各语言文件新增耳机最大音量提示相关文案
- 优化实现,防止拖动时音量显示超出最大音量限制
This commit is contained in:
eafonyang
2026-09-23 19:05:35 +08:00
parent b555372cc8
commit 9d4750fb1e
6 changed files with 79 additions and 21 deletions
+8 -3
View File
@@ -1,6 +1,7 @@
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { import {
DeviceState, DeviceState,
getHomeVolumeMax,
LuxsinAPI, LuxsinAPI,
MOCK_DEVICE_STATE, MOCK_DEVICE_STATE,
MOCK_PEQ_STATE, MOCK_PEQ_STATE,
@@ -211,12 +212,16 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
}, [api, isDemoMode, applyPeqFiltersToState]); }, [api, isDemoMode, applyPeqFiltersToState]);
// Optimistic setters // Optimistic setters
const maxVolume = getHomeVolumeMax(deviceState);
const setVolume = useCallback((v: number) => { 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) { 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) => { const setInput = useCallback((v: number) => {
setDeviceState(prev => prev ? { ...prev, input: v } : prev); setDeviceState(prev => prev ? { ...prev, input: v } : prev);
+26
View File
@@ -21,6 +21,32 @@ export function volumeToDb(vol: number): number {
return (vol - 200) / 2; return (vol - 200) / 2;
} }
/** 耳机输出(协议索引 2)使用设置上限;其他输出及未返回设置的旧固件保持 0dB。 */
export function getHomeVolumeMax(
state: Pick<DeviceState, "output" | "hpMaxVolume"> | 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<DeviceState, "output" | "hpMaxVolume"> | 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 { export function decodeCustomBase64(encoded: string): string {
let translated = ""; let translated = "";
for (let i = 0; i < encoded.length; i++) { for (let i = 0; i < encoded.length; i++) {
+1
View File
@@ -28,6 +28,7 @@
"volumePassthroughBlocked": "Volume passthrough is active ({mode}). Headphone volume is fixed. Turn passthrough off in Audio settings.", "volumePassthroughBlocked": "Volume passthrough is active ({mode}). Headphone volume is fixed. Turn passthrough off in Audio settings.",
"volumePassthroughMode0dB": "0dB", "volumePassthroughMode0dB": "0dB",
"volumePassthroughMode12dB": "-12dB", "volumePassthroughMode12dB": "-12dB",
"hpMaxVolumeHint": "Headphone max volume is set. Maximum: {db}dB",
"powerOffToastDemo": "Demo mode: disconnected from the device.", "powerOffToastDemo": "Demo mode: disconnected from the device.",
"powerOffToastSent": "Shutdown command sent.", "powerOffToastSent": "Shutdown command sent.",
"doubleClickHint": "Double-click to enter", "doubleClickHint": "Double-click to enter",
+1
View File
@@ -28,6 +28,7 @@
"volumePassthroughBlocked": "目前為音量直通模式({mode}),無法調節耳機音量。請在音訊設定中關閉直通或改為「關閉」。", "volumePassthroughBlocked": "目前為音量直通模式({mode}),無法調節耳機音量。請在音訊設定中關閉直通或改為「關閉」。",
"volumePassthroughMode0dB": "0dB", "volumePassthroughMode0dB": "0dB",
"volumePassthroughMode12dB": "-12dB", "volumePassthroughMode12dB": "-12dB",
"hpMaxVolumeHint": "已設定耳機最大音量,最大音量為:{db}dB",
"powerOffToastDemo": "演示模式:已中斷與裝置的連線", "powerOffToastDemo": "演示模式:已中斷與裝置的連線",
"powerOffToastSent": "已傳送關機指令", "powerOffToastSent": "已傳送關機指令",
"doubleClickHint": "按兩下進入", "doubleClickHint": "按兩下進入",
+1
View File
@@ -28,6 +28,7 @@
"volumePassthroughBlocked": "当前为音量直通模式({mode}),无法调节耳机音量。请在音频设置中关闭直通或改为「关闭」。", "volumePassthroughBlocked": "当前为音量直通模式({mode}),无法调节耳机音量。请在音频设置中关闭直通或改为「关闭」。",
"volumePassthroughMode0dB": "0dB", "volumePassthroughMode0dB": "0dB",
"volumePassthroughMode12dB": "-12dB", "volumePassthroughMode12dB": "-12dB",
"hpMaxVolumeHint": "已设置耳机最大音量,最大音量为:{db}dB",
"powerOffToastDemo": "演示模式:已断开与设备的连接", "powerOffToastDemo": "演示模式:已断开与设备的连接",
"powerOffToastSent": "已发送关机指令", "powerOffToastSent": "已发送关机指令",
"doubleClickHint": "双击进入", "doubleClickHint": "双击进入",
+42 -18
View File
@@ -24,6 +24,8 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { import {
getHeadphoneMaxVolumeDb,
getHomeVolumeMax,
INPUT_BLUETOOTH_INDEX, INPUT_BLUETOOTH_INDEX,
INPUT_LABELS, INPUT_LABELS,
isHomeVolumeLockedByPassthrough, isHomeVolumeLockedByPassthrough,
@@ -253,7 +255,10 @@ export default function Home() {
return null; return null;
}, [volumeConfirm, homeText]); }, [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 isDragging = useRef(false);
const knobDraggingRef = useRef(false); const knobDraggingRef = useRef(false);
const knobActivePointerIdRef = useRef<number | null>(null); const knobActivePointerIdRef = useRef<number | null>(null);
@@ -262,10 +267,10 @@ export default function Home() {
const knobLastVolRef = useRef<number>(localVol); const knobLastVolRef = useRef<number>(localVol);
useEffect(() => { useEffect(() => {
if (!isDragging.current && deviceState?.volume !== undefined) { setLocalVol(current => Math.max(0, Math.min(maxVolume,
setLocalVol(deviceState.volume); !isDragging.current && deviceState?.volume !== undefined ? deviceState.volume : current,
} )));
}, [deviceState?.volume]); }, [deviceState?.volume, maxVolume]);
useEffect(() => { useEffect(() => {
knobLastVolRef.current = localVol; knobLastVolRef.current = localVol;
@@ -456,16 +461,25 @@ export default function Home() {
const vuLabel = `${homeText.vu ?? "VU表"}${(ds.vu ?? 0) + 1}`; const vuLabel = `${homeText.vu ?? "VU表"}${(ds.vu ?? 0) + 1}`;
const bypassOn = (ds.dsp_enable ?? 0) === 0; 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 // Home 的 volume 取值为 0..200,对应 dB = (v - 200) / 2
// 因此 1 个 volume 单位 = 0.5dB // 因此 1 个 volume 单位 = 0.5dB
// AudioPage 的 soundStep:0->0.5dB, 1->1dB, 2->2dB, 3->3dB // AudioPage 的 soundStep:0->0.5dB, 1->1dB, 2->2dB, 3->3dB
// 换算为 volume 单位:0.5/1/2/3 dB -> 1/2/4/6 // 换算为 volume 单位:0.5/1/2/3 dB -> 1/2/4/6
const fineVolumeStep = [1, 2, 4, 6][ds.soundStep ?? 1] ?? 1; const fineVolumeStep = [1, 2, 4, 6][ds.soundStep ?? 1] ?? 1;
// Slider fill % (0-200 → 0-100%) // 滑条填充和旋钮角度均按当前输出的最大音量映射。
const fillPct = (localVol / 200) * 100; const fillPct = (localVol / maxVolume) * 100;
// Knob angle (map 0..200 -> -135..+135 degrees) const knobAngle = -135 + (localVol / maxVolume) * 270;
const knobAngle = -135 + (localVol / 200) * 270;
const pointAngle = (clientX: number, clientY: number, rect: DOMRect) => { const pointAngle = (clientX: number, clientY: number, rect: DOMRect) => {
const cx = rect.left + rect.width / 2; const cx = rect.left + rect.width / 2;
@@ -500,9 +514,9 @@ export default function Home() {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const a = pointAngle(e.clientX, e.clientY, rect); const a = pointAngle(e.clientX, e.clientY, rect);
const delta = normalizeDelta(a - knobStartAngleRef.current); const delta = normalizeDelta(a - knobStartAngleRef.current);
// 270deg travel -> full scale; 1 rad maps to ~200/(1.5*pi) volume steps // 270 度行程对应当前允许的完整音量范围。
const stepsPerRad = 200 / (Math.PI * 1.5); const stepsPerRad = maxVolume / (Math.PI * 1.5);
const next = Math.max(0, Math.min(200, Math.round(knobStartVolRef.current + delta * stepsPerRad))); const next = Math.max(0, Math.min(maxVolume, Math.round(knobStartVolRef.current + delta * stepsPerRad)));
setLocalVol(next); setLocalVol(next);
knobLastVolRef.current = next; knobLastVolRef.current = next;
}; };
@@ -691,7 +705,7 @@ export default function Home() {
}} /> }} />
<ThumbOnlyRangeSlider <ThumbOnlyRangeSlider
min={0} min={0}
max={200} max={maxVolume}
value={localVol} value={localVol}
disabled={volumePassthroughActive} disabled={volumePassthroughActive}
className={volumePassthroughActive ? "opacity-50" : undefined} className={volumePassthroughActive ? "opacity-50" : undefined}
@@ -748,7 +762,7 @@ export default function Home() {
}} }}
onClick={() => { onClick={() => {
if (blockVolumeAdjust()) return; if (blockVolumeAdjust()) return;
const next = Math.max(0, Math.min(200, localVol - fineVolumeStep)); const next = Math.max(0, Math.min(maxVolume, localVol - fineVolumeStep));
setLocalVol(next); setLocalVol(next);
applyVolume(next); applyVolume(next);
}} }}
@@ -771,7 +785,7 @@ export default function Home() {
<button <button
type="button" type="button"
aria-label="Increase volume" aria-label="Increase volume"
disabled={volumePassthroughActive || localVol >= 200} disabled={volumePassthroughActive || localVol >= maxVolume}
className="w-10 h-10 rounded-full flex items-center justify-center transition-all active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed" 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={{ style={{
background: "rgba(44,44,46,0.6)", background: "rgba(44,44,46,0.6)",
@@ -780,7 +794,7 @@ export default function Home() {
}} }}
onClick={() => { onClick={() => {
if (blockVolumeAdjust()) return; if (blockVolumeAdjust()) return;
const next = Math.max(0, Math.min(200, localVol + fineVolumeStep)); const next = Math.max(0, Math.min(maxVolume, localVol + fineVolumeStep));
setLocalVol(next); setLocalVol(next);
applyVolume(next); applyVolume(next);
}} }}
@@ -791,6 +805,16 @@ export default function Home() {
</button> </button>
</div> </div>
{hpMaxVolumeHint && (
<p
className="mt-2 text-center text-[12px] leading-relaxed"
style={{ color: "rgba(255,255,255,0.5)" }}
role="status"
>
{hpMaxVolumeHint}
</p>
)}
{/* Bluetooth info — only when input source is Bluetooth */} {/* Bluetooth info — only when input source is Bluetooth */}
{ds.input === INPUT_BLUETOOTH_INDEX && ds.bt_status !== 0 && ( {ds.input === INPUT_BLUETOOTH_INDEX && ds.bt_status !== 0 && (
<div className="mt-3 px-3 py-3 text-center"> <div className="mt-3 px-3 py-3 text-center">
@@ -961,8 +985,8 @@ export default function Home() {
setLocalVol(0); setLocalVol(0);
applyVolume(0); applyVolume(0);
} else if (volumeConfirm === "max") { } else if (volumeConfirm === "max") {
setLocalVol(200); setLocalVol(maxVolume);
applyVolume(200); applyVolume(maxVolume);
} }
setVolumeConfirm(null); setVolumeConfirm(null);
}} }}