feat(device): 增加耳机最大音量限制及显示提示
- 新增 getHomeVolumeMax 函数,计算耳机输出的最大允许音量 - 新增 getHeadphoneMaxVolumeDb 函数,获取耳机最大音量的 dB 值用于提示显示 - 修改 setVolume 函数,限制音量在动态最大值范围内,防止首页入口发送超限音量 - Home 页面音量滑块、旋钮角度及音量范围由固定 200 调整为当前设备最大音量 - 添加耳机最大音量提示文本,根据用户设置动态显示最大 dB 值 - 各语言文件新增耳机最大音量提示相关文案 - 优化实现,防止拖动时音量显示超出最大音量限制
This commit is contained in:
@@ -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,
|
||||||
@@ -213,12 +214,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);
|
||||||
|
|||||||
@@ -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++) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"volumeMaxConfirmOk": "Set to maximum",
|
"volumeMaxConfirmOk": "Set to maximum",
|
||||||
"volumePassthroughLockedToast": "Volume is in passthrough mode and cannot be adjusted.",
|
"volumePassthroughLockedToast": "Volume is in passthrough mode and cannot be adjusted.",
|
||||||
"volumePassthroughLockedHint": "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.",
|
"powerOffToastDemo": "Demo mode: shutdown command sent.",
|
||||||
"powerOffToastSent": "Shutdown command sent.",
|
"powerOffToastSent": "Shutdown command sent.",
|
||||||
"doubleClickHint": "Double-click to enter",
|
"doubleClickHint": "Double-click to enter",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"volumeMaxConfirmOk": "設為最大",
|
"volumeMaxConfirmOk": "設為最大",
|
||||||
"volumePassthroughLockedToast": "目前為音量直通模式,無法調節音量",
|
"volumePassthroughLockedToast": "目前為音量直通模式,無法調節音量",
|
||||||
"volumePassthroughLockedHint": "目前為音量直通模式,無法調節音量",
|
"volumePassthroughLockedHint": "目前為音量直通模式,無法調節音量",
|
||||||
|
"hpMaxVolumeHint": "已設定耳機最大音量,最大音量為:{db}dB",
|
||||||
"powerOffToastDemo": "演示模式:已傳送關機指令",
|
"powerOffToastDemo": "演示模式:已傳送關機指令",
|
||||||
"powerOffToastSent": "已傳送關機指令",
|
"powerOffToastSent": "已傳送關機指令",
|
||||||
"doubleClickHint": "按兩下進入",
|
"doubleClickHint": "按兩下進入",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"volumeMaxConfirmOk": "设为最大",
|
"volumeMaxConfirmOk": "设为最大",
|
||||||
"volumePassthroughLockedToast": "当前为音量直通模式,无法调节音量",
|
"volumePassthroughLockedToast": "当前为音量直通模式,无法调节音量",
|
||||||
"volumePassthroughLockedHint": "当前为音量直通模式,无法调节音量",
|
"volumePassthroughLockedHint": "当前为音量直通模式,无法调节音量",
|
||||||
|
"hpMaxVolumeHint": "已设置耳机最大音量,最大音量为:{db}dB",
|
||||||
"powerOffToastDemo": "演示模式:已发送关机指令",
|
"powerOffToastDemo": "演示模式:已发送关机指令",
|
||||||
"powerOffToastSent": "已发送关机指令",
|
"powerOffToastSent": "已发送关机指令",
|
||||||
"doubleClickHint": "双击进入",
|
"doubleClickHint": "双击进入",
|
||||||
|
|||||||
+41
-19
@@ -23,7 +23,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} 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 { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -250,7 +250,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);
|
||||||
@@ -259,10 +262,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;
|
||||||
@@ -444,16 +447,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;
|
||||||
@@ -488,9 +500,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;
|
||||||
};
|
};
|
||||||
@@ -677,7 +689,7 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
<ThumbOnlyRangeSlider
|
<ThumbOnlyRangeSlider
|
||||||
min={0}
|
min={0}
|
||||||
max={200}
|
max={maxVolume}
|
||||||
value={localVol}
|
value={localVol}
|
||||||
disabled={volumeControlsLocked}
|
disabled={volumeControlsLocked}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
@@ -728,7 +740,7 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
guardVolumeChange(() => {
|
guardVolumeChange(() => {
|
||||||
const next = Math.max(0, Math.min(200, localVol - fineVolumeStep));
|
const next = Math.max(0, Math.min(maxVolume, localVol - fineVolumeStep));
|
||||||
setLocalVol(next);
|
setLocalVol(next);
|
||||||
setVolume(next);
|
setVolume(next);
|
||||||
});
|
});
|
||||||
@@ -744,7 +756,7 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Increase volume"
|
aria-label="Increase volume"
|
||||||
disabled={volumeControlsLocked || localVol >= 200}
|
disabled={volumeControlsLocked || 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)",
|
||||||
@@ -753,7 +765,7 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
guardVolumeChange(() => {
|
guardVolumeChange(() => {
|
||||||
const next = Math.max(0, Math.min(200, localVol + fineVolumeStep));
|
const next = Math.max(0, Math.min(maxVolume, localVol + fineVolumeStep));
|
||||||
setLocalVol(next);
|
setLocalVol(next);
|
||||||
setVolume(next);
|
setVolume(next);
|
||||||
});
|
});
|
||||||
@@ -763,6 +775,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 */}
|
{/* Bluetooth info */}
|
||||||
{ds.bt_status !== 0 && (
|
{ds.bt_status !== 0 && (
|
||||||
<div className="mt-3 px-3 py-3 text-center">
|
<div className="mt-3 px-3 py-3 text-center">
|
||||||
@@ -937,8 +959,8 @@ export default function Home() {
|
|||||||
setLocalVol(0);
|
setLocalVol(0);
|
||||||
setVolume(0);
|
setVolume(0);
|
||||||
} else if (volumeConfirm === "max") {
|
} else if (volumeConfirm === "max") {
|
||||||
setLocalVol(200);
|
setLocalVol(maxVolume);
|
||||||
setVolume(200);
|
setVolume(maxVolume);
|
||||||
}
|
}
|
||||||
setVolumeConfirm(null);
|
setVolumeConfirm(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user