新增氛围灯功能,对 EQ 名称长度做限制

This commit is contained in:
eafonyang
2026-06-30 15:28:53 +08:00
parent 98e8738e79
commit 8283ffcf14
18 changed files with 407 additions and 66 deletions
+13
View File
@@ -42,6 +42,7 @@ import { BrandDrawer } from "./eq/components/BrandDrawer";
import { PeqOverwriteConfirmDialog } from "./eq/components/PeqOverwriteConfirmDialog";
import { PeqPresetManageDialog } from "./eq/components/PeqPresetManageDialog";
import { useRawCurve } from "./eq/hooks/useRawCurve";
import { isPeqPresetNameTooLong } from "./eq/peqPresetName";
import {
BAND_FREQ_MAX,
BAND_FREQ_MIN,
@@ -597,6 +598,10 @@ export default function EQPage() {
toast.error(eqUi.addPresetNameEmpty);
return;
}
if (isPeqPresetNameTooLong(nextName)) {
toast.error(eqUi.addPresetNameTooLong);
return;
}
if (headphoneModels.includes(nextName)) {
toast.error(eqUi.addPresetNameExists);
return;
@@ -675,6 +680,10 @@ export default function EQPage() {
toast.error(eqUi.addPresetNameEmpty);
return;
}
if (isPeqPresetNameTooLong(nextName)) {
toast.error(eqUi.addPresetNameTooLong);
return;
}
if (headphoneModels.includes(nextName)) {
toast.error(eqUi.addPresetNameExists);
return;
@@ -747,6 +756,10 @@ export default function EQPage() {
toast.error(eqUi.addPresetNameEmpty);
return;
}
if (isPeqPresetNameTooLong(nextName)) {
toast.error(eqUi.addPresetNameTooLong);
return;
}
if (!shareCode.trim()) {
toast.error(eqUi.importCodeNotFound);
return;
+174 -2
View File
@@ -11,10 +11,15 @@
import { useDevice } from "@/contexts/DeviceContext";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useLocation } from "wouter";
import { useEffect } from "react";
import { useEffect, useRef, useState } from "react";
import BottomNav from "@/components/BottomNav";
import { navigateToSelect } from "./SelectPage";
import { formatFirmwareVersionDisplay } from "@/lib/luxsinApi";
import {
clampLedChannel,
formatFirmwareVersionDisplay,
parseFirmwareVersion,
supportsAmbientLed,
} 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";
@@ -36,6 +41,60 @@ function SectionHeader({ title }: { title: string }) {
);
}
function CyanSlider({
value,
min,
max,
step = 1,
onChange,
onRelease,
}: {
value: number;
min: number;
max: number;
step?: number;
onChange: (v: number) => void;
onRelease?: (v: number) => void;
}) {
const fillPct = ((value - min) / (max - min)) * 100;
const emitRelease = (el: EventTarget | null) => {
if (!(el instanceof HTMLInputElement)) return;
onRelease?.(Number(el.value));
};
return (
<div className="relative flex items-center w-full mt-2">
<div
className="absolute left-0 h-[4px] rounded-full pointer-events-none"
style={{
width: `${fillPct}%`,
background: "#00FFF6",
boxShadow: "0 0 6px rgba(0,255,246,0.5)",
}}
/>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
className="cyan-slider relative z-10"
onChange={(e) => onChange(Number(e.target.value))}
onPointerUp={(e) => emitRelease(e.currentTarget)}
onPointerCancel={(e) => emitRelease(e.currentTarget)}
onKeyUp={(e) => {
if (
["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(
e.key,
)
) {
emitRelease(e.currentTarget);
}
}}
/>
</div>
);
}
function ListRow({ label, description, value, onClick, toggle, checked, onToggle }: {
label: string; description?: string; value?: string;
onClick?: () => void; toggle?: boolean; checked?: boolean; onToggle?: (v: boolean) => void;
@@ -92,6 +151,11 @@ type LocaleSettings = {
firmwareVersion: string;
macAddress: string;
deviceIp: string;
sectionAmbientLight?: string;
ambientLight?: string;
ledRed?: string;
ledGreen?: string;
ledBlue?: string;
};
};
};
@@ -140,6 +204,29 @@ export default function SystemPage() {
const rawLangIdx = ds?.language ?? 2;
const langIdx = SUPPORTED_LANGUAGE_INDICES.has(rawLangIdx) ? rawLangIdx : 2;
const knobBreathLightOn = (ds?.knob_breathlight ?? 1) === 0;
const firmwareVersion = parseFirmwareVersion(ds?.version);
const showAmbientLed = supportsAmbientLed(firmwareVersion);
const ledOn = (ds?.led_enable ?? 0) === 1;
const [localLedRed, setLocalLedRed] = useState(() => clampLedChannel(ds?.led_red));
const [localLedGreen, setLocalLedGreen] = useState(() => clampLedChannel(ds?.led_green));
const [localLedBlue, setLocalLedBlue] = useState(() => clampLedChannel(ds?.led_blue));
const draggingLedRed = useRef(false);
const draggingLedGreen = useRef(false);
const draggingLedBlue = useRef(false);
useEffect(() => {
if (!draggingLedRed.current) setLocalLedRed(clampLedChannel(ds?.led_red));
}, [ds?.led_red]);
useEffect(() => {
if (!draggingLedGreen.current) setLocalLedGreen(clampLedChannel(ds?.led_green));
}, [ds?.led_green]);
useEffect(() => {
if (!draggingLedBlue.current) setLocalLedBlue(clampLedChannel(ds?.led_blue));
}, [ds?.led_blue]);
const localeData = LOCALE_BY_LANG[langIdx] ?? localeZh;
const settingsText = localeData.settings;
const ui = settingsText.systemPage;
@@ -193,6 +280,91 @@ export default function SystemPage() {
onToggle={(v) => updateSetting({ knob_breathlight: v ? 0 : 1 })} />
</div>
{showAmbientLed && (
<>
<SectionHeader title={ui.sectionAmbientLight ?? "氛围灯"} />
<div className="ios-list-group">
<div className="ios-list-row">
<div className="flex-1" />
<IOSToggle
checked={ledOn}
onChange={(v) => updateSetting({ led_enable: v ? 1 : 0 })}
/>
</div>
{ledOn && (
<div className="p-4 pt-0 space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[14px] text-white/60">{ui.ledRed ?? "红"}</span>
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>
{localLedRed}
</span>
</div>
<CyanSlider
value={localLedRed}
min={0}
max={255}
onChange={(v) => {
draggingLedRed.current = true;
setLocalLedRed(v);
}}
onRelease={(v) => {
draggingLedRed.current = false;
updateSetting({ led_red: clampLedChannel(v) });
}}
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[14px] text-white/60">{ui.ledGreen ?? "绿"}</span>
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>
{localLedGreen}
</span>
</div>
<CyanSlider
value={localLedGreen}
min={0}
max={255}
onChange={(v) => {
draggingLedGreen.current = true;
setLocalLedGreen(v);
}}
onRelease={(v) => {
draggingLedGreen.current = false;
updateSetting({ led_green: clampLedChannel(v) });
}}
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-[14px] text-white/60">{ui.ledBlue ?? "蓝"}</span>
<span className="text-[14px] font-bold" style={{ color: "#00FFF6" }}>
{localLedBlue}
</span>
</div>
<CyanSlider
value={localLedBlue}
min={0}
max={255}
onChange={(v) => {
draggingLedBlue.current = true;
setLocalLedBlue(v);
}}
onRelease={(v) => {
draggingLedBlue.current = false;
updateSetting({ led_blue: clampLedChannel(v) });
}}
/>
</div>
</div>
)}
</div>
</>
)}
{/* ── 通用 ── */}
<SectionHeader title={ui.sectionGeneral} />
<div className="ios-list-group">
@@ -1,6 +1,9 @@
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { PeqEqUi } from "../types";
import { PEQ_PRESET_NAME_MAX_LENGTH } from "../eqConstants";
import { clampPeqPresetName } from "../peqPresetName";
import { PeqPresetNameLengthHint } from "./PeqPresetNameLengthHint";
export function AddPresetDialog({
open,
@@ -63,12 +66,16 @@ export function AddPresetDialog({
onChange={() => onModeChange("copy")}
className="h-4 w-4 shrink-0 accent-[#00FFF6]"
/>
<input
value={copyName}
onChange={(e) => onCopyNameChange(e.target.value)}
onClick={(e) => e.stopPropagation()}
className="h-10 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none ring-0 ring-offset-0 placeholder:text-white/30 focus:border-white/15 focus:ring-0 focus-visible:ring-0"
/>
<div className="min-w-0 flex-1">
<input
value={copyName}
onChange={(e) => onCopyNameChange(clampPeqPresetName(e.target.value))}
onClick={(e) => e.stopPropagation()}
maxLength={PEQ_PRESET_NAME_MAX_LENGTH}
className="h-10 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none ring-0 ring-offset-0 placeholder:text-white/30 focus:border-white/15 focus:ring-0 focus-visible:ring-0"
/>
<PeqPresetNameLengthHint value={copyName} />
</div>
</label>
<label
@@ -84,12 +91,16 @@ export function AddPresetDialog({
onChange={() => onModeChange("flat")}
className="h-4 w-4 shrink-0 accent-[#00FFF6]"
/>
<input
value={flatName}
onChange={(e) => onFlatNameChange(e.target.value)}
onClick={(e) => e.stopPropagation()}
className="h-10 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none ring-0 ring-offset-0 placeholder:text-white/30 focus:border-white/15 focus:ring-0 focus-visible:ring-0"
/>
<div className="min-w-0 flex-1">
<input
value={flatName}
onChange={(e) => onFlatNameChange(clampPeqPresetName(e.target.value))}
onClick={(e) => e.stopPropagation()}
maxLength={PEQ_PRESET_NAME_MAX_LENGTH}
className="h-10 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none ring-0 ring-offset-0 placeholder:text-white/30 focus:border-white/15 focus:ring-0 focus-visible:ring-0"
/>
<PeqPresetNameLengthHint value={flatName} />
</div>
</label>
</div>
@@ -3,6 +3,9 @@ import { X, Trash2, Pencil, Check, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import type { PeqEqUi } from "../types";
import { PEQ_PRESET_NAME_MAX_LENGTH } from "../eqConstants";
import { clampPeqPresetName, isPeqPresetNameTooLong } from "../peqPresetName";
import { PeqPresetNameLengthHint } from "./PeqPresetNameLengthHint";
/* ── Custom checkbox ── */
function Checkbox({
@@ -133,6 +136,10 @@ export function PeqPresetManageDialog({
toast.error(eqUi.renameEmptyName);
return;
}
if (isPeqPresetNameTooLong(nextName)) {
toast.error(eqUi.addPresetNameTooLong);
return;
}
const oldItem = items[editingIdx];
if (!oldItem) return;
if (nextName === oldItem.name) {
@@ -266,12 +273,14 @@ export function PeqPresetManageDialog({
<div className="min-w-0 flex-1">
{isEditing ? (
<div className="flex items-center gap-2">
<input
value={editingName}
onChange={e => setEditingName(e.target.value)}
className="h-9 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[14px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40"
/>
<div className="space-y-1">
<div className="flex items-center gap-2">
<input
value={editingName}
onChange={e => setEditingName(clampPeqPresetName(e.target.value))}
maxLength={PEQ_PRESET_NAME_MAX_LENGTH}
className="h-9 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[14px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40"
/>
<button
type="button"
disabled={renaming}
@@ -300,6 +309,8 @@ export function PeqPresetManageDialog({
>
</button>
</div>
<PeqPresetNameLengthHint value={editingName} />
</div>
) : (
<div
@@ -0,0 +1,9 @@
import { peqPresetNameLengthText } from "../peqPresetName";
export function PeqPresetNameLengthHint({ value }: { value: string }) {
return (
<p className="mt-1 text-right text-[11px] text-white/35 tabular-nums">
{peqPresetNameLengthText(value)}
</p>
);
}
+13 -6
View File
@@ -1,5 +1,8 @@
import { X } from "lucide-react";
import type { PeqEqUi } from "../types";
import { PEQ_PRESET_NAME_MAX_LENGTH } from "../eqConstants";
import { clampPeqPresetName } from "../peqPresetName";
import { PeqPresetNameLengthHint } from "./PeqPresetNameLengthHint";
export function SaveBDialog({
open,
@@ -40,12 +43,16 @@ export function SaveBDialog({
</button>
</div>
<input
value={presetName}
onChange={(e) => onNameChange(e.target.value)}
className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-white/15"
autoFocus
/>
<div>
<input
value={presetName}
onChange={(e) => onNameChange(clampPeqPresetName(e.target.value))}
maxLength={PEQ_PRESET_NAME_MAX_LENGTH}
className="h-11 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[15px] text-white/90 outline-none placeholder:text-white/30 focus:border-white/15"
autoFocus
/>
<PeqPresetNameLengthHint value={presetName} />
</div>
<div className="mt-6 flex items-center justify-center gap-4 sm:gap-8">
<button
+25 -13
View File
@@ -7,6 +7,9 @@ import type { PeqEqUi } from "../types";
import { resolveShareApiMessage } from "../shareMessages";
import { eqInterp } from "../eqFormatters";
import { PeqOverwriteConfirmDialog } from "./PeqOverwriteConfirmDialog";
import { PEQ_PRESET_NAME_MAX_LENGTH } from "../eqConstants";
import { clampPeqPresetName, isPeqPresetNameTooLong } from "../peqPresetName";
import { PeqPresetNameLengthHint } from "./PeqPresetNameLengthHint";
type PeqItem = {
name: string;
@@ -100,6 +103,10 @@ export function ShareDialog({
if (importSaving || !importedEqData || !queriedShareCode) return;
const nextName = importPresetName.trim();
if (!nextName) return;
if (isPeqPresetNameTooLong(nextName)) {
toast.error(eqUi.addPresetNameTooLong);
return;
}
setImportSaving(true);
try {
@@ -144,7 +151,7 @@ export function ShareDialog({
return (
<>
<div
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 px-4"
className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto bg-black/65 px-4 pb-8 pt-[22vh]"
onClick={(e) => {
if (e.target !== e.currentTarget) return;
if (importSaving || deleteConfirmCode !== null || deletingShareCode) return;
@@ -152,7 +159,7 @@ export function ShareDialog({
}}
>
<div
className="w-full max-w-[420px] rounded-[14px] p-5"
className="flex w-full max-w-[420px] flex-col rounded-[14px] p-5"
style={{
background: "linear-gradient(180deg, rgba(34,36,40,0.98) 0%, rgba(24,26,30,0.98) 100%)",
border: "1px solid rgba(255,255,255,0.12)",
@@ -161,13 +168,16 @@ export function ShareDialog({
onClick={(e) => e.stopPropagation()}
>
{/* header */}
<div className="flex items-center justify-between mb-4">
<div className="mb-4 flex shrink-0 items-center gap-2">
{/* tab bar */}
<div className="flex gap-1 rounded-[10px] p-1" style={{ background: "rgba(44,44,46,0.8)" }}>
<div
className="grid min-w-0 flex-1 grid-cols-3 gap-1 rounded-[10px] p-1"
style={{ background: "rgba(44,44,46,0.8)" }}
>
<button
type="button"
className={cn(
"px-3 py-1.5 rounded-[8px] text-[13px] font-medium transition-colors",
"h-[34px] truncate rounded-[8px] px-2 text-center text-[13px] font-medium transition-colors",
shareDialogTab === "share"
? "bg-[#00FFF6] text-black font-semibold"
: "text-white/60 hover:text-white/80",
@@ -179,7 +189,7 @@ export function ShareDialog({
<button
type="button"
className={cn(
"px-3 py-1.5 rounded-[8px] text-[13px] font-medium transition-colors",
"h-[34px] truncate rounded-[8px] px-2 text-center text-[13px] font-medium transition-colors",
shareDialogTab === "import"
? "bg-[#00FFF6] text-black font-semibold"
: "text-white/60 hover:text-white/80",
@@ -191,7 +201,7 @@ export function ShareDialog({
<button
type="button"
className={cn(
"px-3 py-1.5 rounded-[8px] text-[13px] font-medium transition-colors",
"h-[34px] truncate rounded-[8px] px-2 text-center text-[13px] font-medium transition-colors",
shareDialogTab === "myShares"
? "bg-[#00FFF6] text-black font-semibold"
: "text-white/60 hover:text-white/80",
@@ -222,7 +232,7 @@ export function ShareDialog({
</div>
<button
type="button"
className="rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80 disabled:opacity-40 disabled:pointer-events-none"
className="shrink-0 rounded-full p-1.5 text-white/45 transition-colors hover:bg-white/10 hover:text-white/80 disabled:pointer-events-none disabled:opacity-40"
disabled={importSaving}
onClick={onClose}
>
@@ -238,7 +248,7 @@ export function ShareDialog({
{/* EQ preset list */}
<div
className="rounded-[10px] overflow-hidden max-h-48 overflow-y-auto mb-4"
className="mb-4 max-h-48 overflow-hidden overflow-y-auto rounded-[10px]"
style={{
background: "rgba(10,12,16,0.98)",
border: "1px solid rgba(0,255,246,0.35)",
@@ -482,7 +492,7 @@ export function ShareDialog({
preamp: eq.preamp as number | undefined,
autoPre: eq.autoPre as number | undefined,
});
setImportPresetName(fetchedName);
setImportPresetName(clampPeqPresetName(fetchedName));
setImportedSourceModel(res.model ?? null);
setQueriedShareCode(code);
} catch {
@@ -517,11 +527,13 @@ export function ShareDialog({
</p>
<input
value={importPresetName}
onChange={(e) => setImportPresetName(e.target.value)}
onChange={(e) => setImportPresetName(clampPeqPresetName(e.target.value))}
disabled={importSaving}
maxLength={PEQ_PRESET_NAME_MAX_LENGTH}
className="h-10 w-full rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[14px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40 disabled:opacity-60"
placeholder={eqUi.importEqName}
/>
<PeqPresetNameLengthHint value={importPresetName} />
</div>
<button
type="button"
@@ -552,7 +564,7 @@ export function ShareDialog({
{shareDialogTab === "myShares" && (
<>
{mySharesLoading && mySharesList.length === 0 ? (
<div className="flex items-center justify-center py-10 gap-2 text-white/50">
<div className="flex items-center justify-center gap-2 py-10 text-white/50">
<Loader2 size={16} className="animate-spin" />
<span className="text-[13px]">{eqUi.mySharesLoading}</span>
</div>
@@ -560,7 +572,7 @@ export function ShareDialog({
<div className="py-10 text-center text-[13px] text-white/30">{eqUi.mySharesEmpty}</div>
) : (
<div
className="rounded-[10px] overflow-hidden max-h-64 overflow-y-auto"
className="max-h-64 overflow-hidden overflow-y-auto rounded-[10px]"
style={{
background: "rgba(10,12,16,0.98)",
border: "1px solid rgba(0,255,246,0.35)",
+3
View File
@@ -14,6 +14,9 @@ export const BAND_Q_MAX = 10;
/** Maximum headphone PEQ presets stored on device. */
export const PEQ_PRESET_MAX = 50;
/** Maximum characters for a custom PEQ preset name (letter, CJK, symbol, or space = 1). */
export const PEQ_PRESET_NAME_MAX_LENGTH = 80;
export const BAND_PARAM_VALUE_BOX_STYLE: CSSProperties = {
background: "rgba(44,44,46,0.9)",
border: "1px solid rgba(255,255,255,0.08)",
+5 -3
View File
@@ -1,6 +1,7 @@
import { getFilterType } from "@/lib/peqAudio";
import { normalizePeqFiltersForSubmit, type PeqFilter, type PeqState } from "@/lib/luxsinApi";
import type { EqBand } from "./types";
import { clampPeqPresetName } from "./peqPresetName";
export function cloneBands(source: EqBand[]) {
return source.map((band) => ({ ...band }));
@@ -32,12 +33,13 @@ export function buildPeqCatalogSyncKey(
}
export function getUniquePresetName(base: string, existingNames: string[]) {
if (!existingNames.includes(base)) return base;
const clippedBase = clampPeqPresetName(base);
if (!existingNames.includes(clippedBase)) return clippedBase;
let index = 1;
while (existingNames.includes(`${base}_${index}`)) {
while (existingNames.includes(clampPeqPresetName(`${clippedBase}_${index}`))) {
index += 1;
}
return `${base}_${index}`;
return clampPeqPresetName(`${clippedBase}_${index}`);
}
/** UI band row → device `PeqFilter` (fc + numeric type), same as legacy `getFilterVal` mapping via `getFilterType`. */
+13
View File
@@ -0,0 +1,13 @@
import { PEQ_PRESET_NAME_MAX_LENGTH } from "./eqConstants";
export function clampPeqPresetName(value: string): string {
return value.slice(0, PEQ_PRESET_NAME_MAX_LENGTH);
}
export function isPeqPresetNameTooLong(value: string): boolean {
return value.trim().length > PEQ_PRESET_NAME_MAX_LENGTH;
}
export function peqPresetNameLengthText(value: string): string {
return `${value.length}/${PEQ_PRESET_NAME_MAX_LENGTH}`;
}