Refactor BottomNav to use localized labels for navigation items; added support for dynamic language selection based on device state. Updated locale files for English, Traditional Chinese, and Simplified Chinese to include bottom navigation labels.
This commit is contained in:
@@ -1,13 +1,17 @@
|
||||
/* ============================================================
|
||||
BOTTOM NAV — Luxsin X9 Controller (Mobile)
|
||||
Design: iOS/Android App-style bottom tab bar
|
||||
5 tabs: 社区 / HP-EQ / 主页 / Effect / 我的AI
|
||||
5 tabs — labels from locales/bottomNav, language = deviceState.language (同系统页)
|
||||
- Active tab: cyan icon + top indicator bar
|
||||
- Inactive: muted icon + label
|
||||
============================================================ */
|
||||
import { useDevice } from "@/contexts/DeviceContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAIDrawer } from "@/contexts/AIDrawerContext";
|
||||
import { useIsLgUp } from "@/hooks/useIsLgUp";
|
||||
import localeZh from "@/locales/data-zh.json";
|
||||
import localeZhHK from "@/locales/data-zh-HK.json";
|
||||
import localeEn from "@/locales/data-en.json";
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
@@ -16,25 +20,36 @@ import {
|
||||
Sliders,
|
||||
} from "lucide-react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { useMemo } from "react";
|
||||
|
||||
type BottomNavLocale = NonNullable<(typeof localeZh)["bottomNav"]>;
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
label: string;
|
||||
labelKey: keyof BottomNavLocale;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ path: "/community", icon: MessageSquare, label: "社区" },
|
||||
{ path: "/eq", icon: Sliders, label: "HP-EQ" },
|
||||
{ path: "/", icon: Home, label: "主页" },
|
||||
{ path: "/effects", icon: Activity, label: "Effect" },
|
||||
{ path: "/ai", icon: Bot, label: "我的AI" },
|
||||
{ path: "/community", icon: MessageSquare, labelKey: "community" },
|
||||
{ path: "/eq", icon: Sliders, labelKey: "hpEq" },
|
||||
{ path: "/", icon: Home, labelKey: "home" },
|
||||
{ path: "/effects", icon: Activity, labelKey: "effects" },
|
||||
{ path: "/ai", icon: Bot, labelKey: "myAi" },
|
||||
];
|
||||
|
||||
export default function BottomNav() {
|
||||
const [location] = useLocation();
|
||||
const isLgUp = useIsLgUp();
|
||||
const { open: aiDrawerOpen, setOpen: setAiDrawerOpen } = useAIDrawer();
|
||||
const { deviceState } = useDevice();
|
||||
|
||||
const navText = useMemo((): BottomNavLocale => {
|
||||
const lang = deviceState?.language;
|
||||
const pack =
|
||||
lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
|
||||
return (pack.bottomNav ?? localeZh.bottomNav) as BottomNavLocale;
|
||||
}, [deviceState?.language]);
|
||||
|
||||
return (
|
||||
<nav
|
||||
@@ -102,7 +117,7 @@ export default function BottomNav() {
|
||||
textShadow: isActive ? "0 0 8px rgba(0,255,246,0.5)" : "none",
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
{navText[item.labelKey]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"home": {
|
||||
"audioSet": "Audio setting",
|
||||
"systemSet": "System",
|
||||
"power": "Power",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
@@ -12,6 +13,16 @@
|
||||
"powerOffToastDemo": "Demo mode: disconnected from the device.",
|
||||
"powerOffToastSent": "Shutdown command sent."
|
||||
},
|
||||
"vuPage": {
|
||||
"title": "VU meter gallery"
|
||||
},
|
||||
"bottomNav": {
|
||||
"community": "Community",
|
||||
"hpEq": "HP-EQ",
|
||||
"home": "Home",
|
||||
"effects": "Effects",
|
||||
"myAi": "My AI"
|
||||
},
|
||||
"source": {
|
||||
"label": "Input/Output",
|
||||
"input": {
|
||||
@@ -134,7 +145,7 @@
|
||||
"Standard": "Standard equalizer preset",
|
||||
"edit": "Batch Edit",
|
||||
"headset": "Add HeadSet",
|
||||
"preamp": "Preamp",
|
||||
"preamp": "Total gain",
|
||||
"filters": {
|
||||
"label": "Filters",
|
||||
"options": [
|
||||
@@ -203,7 +214,57 @@
|
||||
"gain": "GAIN",
|
||||
"q": "Q value",
|
||||
"loadDeviceBtn": "confirm",
|
||||
"peqNoneItem": "Unconfigured"
|
||||
"peqNoneItem": "Unconfigured",
|
||||
"eqUi": {
|
||||
"batchEditTitle": "Batch edit",
|
||||
"batchEditHint": "11 lines total: line 1 is Preamp, lines 2–11 are Filter 1–10. Saving overwrites the current headphone preset.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"tabBrands": "Brands",
|
||||
"tabModels": "Models",
|
||||
"tabTarget": "Target",
|
||||
"searchPlaceholder": "Search brand or model",
|
||||
"searching": "Searching…",
|
||||
"noSearchResults": "No matching brand or model",
|
||||
"loading": "Loading…",
|
||||
"noBrandMatch": "No matching brands",
|
||||
"selectBrandFirst": "Choose a brand on the Brands tab first",
|
||||
"brandLabel": "Brand:",
|
||||
"modelFromSearchLabel": "Model from search:",
|
||||
"noModelsForBrand": "No models for this brand",
|
||||
"targetBrand": "Brand:",
|
||||
"targetModel": "Model:",
|
||||
"targetForm": "Form:",
|
||||
"formAll": "All",
|
||||
"confirmLoading": "Working…",
|
||||
"confirmButton": "Confirm",
|
||||
"selectModelFirst": "Select a model first",
|
||||
"noTargets": "No target curves available",
|
||||
"closeDrawer": "Close",
|
||||
"toastBatchApplied": "Batch edit applied",
|
||||
"toastBatchParseError": "Invalid batch edit format",
|
||||
"toastBrandsFail": "Failed to load brands",
|
||||
"toastModelsFail": "Failed to load models",
|
||||
"toastCurveFail": "Failed to load curve",
|
||||
"toastNewEqOk": "New headphone EQ saved",
|
||||
"toastTargetSelected": "Target selected: {{name}}",
|
||||
"deleteConfirm": "Delete headphone \"{{name}}\"?",
|
||||
"toastDeleted": "Headphone removed",
|
||||
"toastDeleteFail": "Failed to delete headphone",
|
||||
"bassBoost": "Bass boost: fc {{fc}}, q {{q}}, gain {{gain}} dB",
|
||||
"errMinLines": "At least 11 lines required (Preamp + 10 filters)",
|
||||
"errPreampFirstLine": "Line 1 must look like Preamp:-5.96dB",
|
||||
"errPreampValue": "Invalid Preamp value",
|
||||
"errFilterLine": "Line {{line}} does not match the Filter format",
|
||||
"errFilterNoRange": "Filter number {{no}} is out of range",
|
||||
"errFilterType": "Invalid type for filter {{no}} ({{type}})",
|
||||
"errFilterNumbers": "Invalid numbers for filter {{no}}",
|
||||
"errFilterAll": "Filters 1–10 must all be present",
|
||||
"chartCopyTo": "Copy to {{mode}}",
|
||||
"chartLoadToDevice": "Load to device",
|
||||
"toastChartCopied": "Copied to {{mode}}",
|
||||
"toastChartLoadedDevice": "Loaded to device"
|
||||
}
|
||||
},
|
||||
"dac": {
|
||||
"balance": "Left-right balance",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"home": {
|
||||
"audioSet": "音訊設定",
|
||||
"systemSet": "系統設定",
|
||||
"power": "電源",
|
||||
"input": "輸入源",
|
||||
"output": "輸出端口",
|
||||
@@ -12,6 +13,16 @@
|
||||
"powerOffToastDemo": "演示模式:已中斷與裝置的連線",
|
||||
"powerOffToastSent": "已傳送關機指令"
|
||||
},
|
||||
"vuPage": {
|
||||
"title": "VU 表庫"
|
||||
},
|
||||
"bottomNav": {
|
||||
"community": "社區",
|
||||
"hpEq": "HP-EQ",
|
||||
"home": "主頁",
|
||||
"effects": "Effect",
|
||||
"myAi": "我的AI"
|
||||
},
|
||||
"source": {
|
||||
"label": "輸入/輸出",
|
||||
"input": { "coaxial": "同軸", "optical": "光纖", "bluetooth": "藍牙", "rca": "類比RCA" },
|
||||
@@ -137,7 +148,57 @@
|
||||
"gain": "GAIN",
|
||||
"q": "Q值",
|
||||
"loadDeviceBtn": "確認載入",
|
||||
"peqNoneItem": "尚未設定"
|
||||
"peqNoneItem": "尚未設定",
|
||||
"eqUi": {
|
||||
"batchEditTitle": "批次編輯",
|
||||
"batchEditHint": "共 11 行:第 1 行為 Preamp,第 2–11 行為 Filter 1–10。儲存後將覆寫目前耳機的參數。",
|
||||
"cancel": "取消",
|
||||
"save": "儲存",
|
||||
"tabBrands": "品牌",
|
||||
"tabModels": "型號",
|
||||
"tabTarget": "目標",
|
||||
"searchPlaceholder": "搜尋品牌或型號",
|
||||
"searching": "搜尋中…",
|
||||
"noSearchResults": "找不到相關品牌或型號",
|
||||
"loading": "載入中…",
|
||||
"noBrandMatch": "沒有符合的品牌",
|
||||
"selectBrandFirst": "請先在「品牌」頁選擇品牌",
|
||||
"brandLabel": "品牌:",
|
||||
"modelFromSearchLabel": "已選搜尋型號:",
|
||||
"noModelsForBrand": "此品牌暫無型號",
|
||||
"targetBrand": "品牌:",
|
||||
"targetModel": "型號:",
|
||||
"targetForm": "Form:",
|
||||
"formAll": "全部",
|
||||
"confirmLoading": "處理中…",
|
||||
"confirmButton": "確認",
|
||||
"selectModelFirst": "請先選擇型號",
|
||||
"noTargets": "暫無可用目標曲線",
|
||||
"closeDrawer": "關閉",
|
||||
"toastBatchApplied": "批次編輯已套用",
|
||||
"toastBatchParseError": "批次編輯格式錯誤",
|
||||
"toastBrandsFail": "取得品牌列表失敗",
|
||||
"toastModelsFail": "取得型號列表失敗",
|
||||
"toastCurveFail": "取得曲線失敗",
|
||||
"toastNewEqOk": "新耳機 EQ 已上報",
|
||||
"toastTargetSelected": "已選目標:{{name}}",
|
||||
"deleteConfirm": "是否刪除耳機「{{name}}」?",
|
||||
"toastDeleted": "已刪除耳機",
|
||||
"toastDeleteFail": "刪除耳機失敗",
|
||||
"bassBoost": "低頻增強:fc {{fc}},q {{q}},gain {{gain}}dB",
|
||||
"errMinLines": "至少需要 11 行(Preamp + 10 個濾波器)",
|
||||
"errPreampFirstLine": "第 1 行應為 Preamp:-5.96dB 格式",
|
||||
"errPreampValue": "Preamp 數值無效",
|
||||
"errFilterLine": "第 {{line}} 行不符合 Filter 格式",
|
||||
"errFilterNoRange": "Filter 編號 {{no}} 超出範圍",
|
||||
"errFilterType": "Filter {{no}} 的類型無效({{type}})",
|
||||
"errFilterNumbers": "Filter {{no}} 的數值無效",
|
||||
"errFilterAll": "Filter 1–10 必須全部提供",
|
||||
"chartCopyTo": "複製到 {{mode}}",
|
||||
"chartLoadToDevice": "載入至裝置",
|
||||
"toastChartCopied": "已複製到 {{mode}}",
|
||||
"toastChartLoadedDevice": "已載入至裝置"
|
||||
}
|
||||
},
|
||||
"dac": {
|
||||
"balance": "左右平衡",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"home": {
|
||||
"audioSet": "音频设置",
|
||||
"systemSet": "系统设置",
|
||||
"power": "电源",
|
||||
"input": "输入源",
|
||||
"output": "输出端口",
|
||||
@@ -12,6 +13,16 @@
|
||||
"powerOffToastDemo": "演示模式:已断开与设备的连接",
|
||||
"powerOffToastSent": "已发送关机指令"
|
||||
},
|
||||
"vuPage": {
|
||||
"title": "VU 表库"
|
||||
},
|
||||
"bottomNav": {
|
||||
"community": "社区",
|
||||
"hpEq": "HP-EQ",
|
||||
"home": "主页",
|
||||
"effects": "Effect",
|
||||
"myAi": "我的AI"
|
||||
},
|
||||
"source": {
|
||||
"label": "输入输出",
|
||||
"input": { "coaxial": "同轴", "optical": "光钎", "bluetooth": "蓝牙", "rca": "模拟RCA" },
|
||||
@@ -137,7 +148,57 @@
|
||||
"gain": "GAIN",
|
||||
"q": "Q值",
|
||||
"loadDeviceBtn": "确定载入",
|
||||
"peqNoneItem": "未配置"
|
||||
"peqNoneItem": "未配置",
|
||||
"eqUi": {
|
||||
"batchEditTitle": "批量编辑",
|
||||
"batchEditHint": "共 11 行:第 1 行为 Preamp,第 2–11 行为 Filter 1–10。保存后将覆盖当前耳机的参数。",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"tabBrands": "品牌",
|
||||
"tabModels": "型号",
|
||||
"tabTarget": "目标",
|
||||
"searchPlaceholder": "搜索品牌或型号",
|
||||
"searching": "搜索中…",
|
||||
"noSearchResults": "未找到相关品牌或型号",
|
||||
"loading": "加载中…",
|
||||
"noBrandMatch": "无匹配品牌",
|
||||
"selectBrandFirst": "请先在「品牌」页选择品牌",
|
||||
"brandLabel": "品牌:",
|
||||
"modelFromSearchLabel": "已选搜索型号:",
|
||||
"noModelsForBrand": "该品牌暂无型号",
|
||||
"targetBrand": "品牌:",
|
||||
"targetModel": "型号:",
|
||||
"targetForm": "Form:",
|
||||
"formAll": "全部",
|
||||
"confirmLoading": "处理中…",
|
||||
"confirmButton": "确认",
|
||||
"selectModelFirst": "请先选择型号",
|
||||
"noTargets": "暂无可用目标曲线",
|
||||
"closeDrawer": "关闭",
|
||||
"toastBatchApplied": "批量编辑已应用",
|
||||
"toastBatchParseError": "批量编辑格式错误",
|
||||
"toastBrandsFail": "获取品牌列表失败",
|
||||
"toastModelsFail": "获取型号列表失败",
|
||||
"toastCurveFail": "获取曲线失败",
|
||||
"toastNewEqOk": "新耳机 EQ 已上报",
|
||||
"toastTargetSelected": "已选目标:{{name}}",
|
||||
"deleteConfirm": "是否删除耳机「{{name}}」?",
|
||||
"toastDeleted": "已删除耳机",
|
||||
"toastDeleteFail": "删除耳机失败",
|
||||
"bassBoost": "低频增强:fc {{fc}},q {{q}},gain {{gain}}dB",
|
||||
"errMinLines": "至少需要 11 行(Preamp + 10 个滤波器)",
|
||||
"errPreampFirstLine": "第 1 行应为 Preamp:-5.96dB 格式",
|
||||
"errPreampValue": "Preamp 数值无效",
|
||||
"errFilterLine": "第 {{line}} 行不符合 Filter 格式",
|
||||
"errFilterNoRange": "Filter 编号 {{no}} 超出范围",
|
||||
"errFilterType": "Filter {{no}} 的类型无效({{type}})",
|
||||
"errFilterNumbers": "Filter {{no}} 的数值无效",
|
||||
"errFilterAll": "Filter 1–10 必须全部提供",
|
||||
"chartCopyTo": "复制到 {{mode}}",
|
||||
"chartLoadToDevice": "加载至设备",
|
||||
"toastChartCopied": "已复制到 {{mode}}",
|
||||
"toastChartLoadedDevice": "已加载至设备"
|
||||
}
|
||||
},
|
||||
"dac": {
|
||||
"balance": "左右平衡",
|
||||
|
||||
+115
-57
@@ -39,6 +39,20 @@ import {
|
||||
getFilterShortName,
|
||||
buildPeqSvgCurveData,
|
||||
} from "@/lib/peqAudio";
|
||||
import localeZh from "@/locales/data-zh.json";
|
||||
import localeZhHK from "@/locales/data-zh-HK.json";
|
||||
import localeEn from "@/locales/data-en.json";
|
||||
|
||||
type PeqEqUi = NonNullable<NonNullable<(typeof localeZh)["peq"]>["eqUi"]>;
|
||||
|
||||
function eqInterp(template: string | undefined, vars: Record<string, string | number>): string {
|
||||
if (!template) return "";
|
||||
let s = template;
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
s = s.split(`{{${k}}}`).join(String(v));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/* ── iOS Toggle ── */
|
||||
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
@@ -94,7 +108,15 @@ function normalizeFilterType(type: string | number | undefined): string {
|
||||
|
||||
/* ── Frequency Response Chart ── */
|
||||
function FreqChart({
|
||||
bands, rawCurve, selectedBand, abMode, onAbToggle, onCopyMode, onBandDrag, onBandSelect,
|
||||
bands,
|
||||
rawCurve,
|
||||
selectedBand,
|
||||
abMode,
|
||||
onAbToggle,
|
||||
onCopyMode,
|
||||
onBandDrag,
|
||||
onBandSelect,
|
||||
eqUi,
|
||||
}: {
|
||||
bands: Array<{ freq: number; gain: number; q: number; type: string; enabled: boolean }>;
|
||||
rawCurve: number[] | null;
|
||||
@@ -104,6 +126,7 @@ function FreqChart({
|
||||
onCopyMode: (from: "A" | "B", to: "A" | "B") => void;
|
||||
onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
|
||||
onBandSelect: (idx: number) => void;
|
||||
eqUi: PeqEqUi;
|
||||
}) {
|
||||
const H = 140;
|
||||
const chartContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -252,7 +275,7 @@ function FreqChart({
|
||||
});
|
||||
|
||||
const targetMode: "A" | "B" = abMode === "A" ? "B" : "A";
|
||||
const copyButtonText = abMode === "A" ? "复制到 B" : "复制到 A";
|
||||
const copyButtonText = eqInterp(eqUi.chartCopyTo, { mode: targetMode });
|
||||
|
||||
const freqLabels = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000];
|
||||
const gainLabels = [20, 15, 10, 5, 0, -5, -10, -15, -20];
|
||||
@@ -315,16 +338,16 @@ function FreqChart({
|
||||
[targetMode]: { ...prev[abMode] },
|
||||
}));
|
||||
onAbToggle(targetMode);
|
||||
toast.success(`已复制到 ${targetMode}`);
|
||||
toast.success(eqInterp(eqUi.toastChartCopied, { mode: targetMode }));
|
||||
}}>
|
||||
{copyButtonText}
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1 rounded-[8px] text-[11px] text-white/50 active:text-white transition-colors"
|
||||
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
onClick={() => toast.info("已加载至设备")}
|
||||
onClick={() => toast.info(eqUi.toastChartLoadedDevice)}
|
||||
>
|
||||
加载至设备
|
||||
{eqUi.chartLoadToDevice}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -544,6 +567,19 @@ export default function EQPage() {
|
||||
const { deviceState, updateSetting, api, isDemoMode, upgradePeqChange } = useDevice();
|
||||
const eqOn = (deviceState?.peqEnable ?? 0) === 1;
|
||||
|
||||
const eqUi = useMemo((): PeqEqUi => {
|
||||
const lang = deviceState?.language;
|
||||
const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
|
||||
const peq = pack.peq as typeof localeZh.peq;
|
||||
return (peq.eqUi ?? (localeZh.peq as typeof localeZh.peq).eqUi) as PeqEqUi;
|
||||
}, [deviceState?.language]);
|
||||
|
||||
const peqCardLabels = useMemo(() => {
|
||||
const lang = deviceState?.language;
|
||||
const pack = lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
|
||||
return pack.peq ?? localeZh.peq;
|
||||
}, [deviceState?.language]);
|
||||
|
||||
const [bandsByMode, setBandsByMode] = useState<Record<"A" | "B", typeof DEFAULT_BANDS>>({
|
||||
A: cloneBands(DEFAULT_BANDS),
|
||||
B: cloneBands(DEFAULT_BANDS),
|
||||
@@ -660,11 +696,11 @@ export default function EQPage() {
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "加载失败";
|
||||
setCatalogBrandsError(msg);
|
||||
toast.error("获取品牌列表失败");
|
||||
toast.error(eqUi.toastBrandsFail);
|
||||
} finally {
|
||||
setCatalogBrandsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [eqUi]);
|
||||
|
||||
const searchCatalogByKeyword = useCallback(async (keyword: string) => {
|
||||
const q = keyword.trim();
|
||||
@@ -699,11 +735,11 @@ export default function EQPage() {
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "加载失败";
|
||||
setCatalogModelsError(msg);
|
||||
toast.error("获取型号列表失败");
|
||||
toast.error(eqUi.toastModelsFail);
|
||||
} finally {
|
||||
setCatalogModelsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [eqUi]);
|
||||
|
||||
const openAddHeadsetCatalog = () => {
|
||||
setIsBrandDrawerOpen(true);
|
||||
@@ -792,22 +828,24 @@ export default function EQPage() {
|
||||
setIsBatchEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const parseBatchEditText = (raw: string) => {
|
||||
const parseBatchEditText = useCallback(
|
||||
(raw: string) => {
|
||||
const t = eqUi;
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length < 11) {
|
||||
throw new Error("格式错误:至少需要 11 行(Preamp + 10 个滤波器)");
|
||||
throw new Error(eqInterp(t.errMinLines, {}));
|
||||
}
|
||||
|
||||
const preampMatch = lines[0].match(/^Preamp\s*:\s*([+-]?\d+(?:\.\d+)?)\s*dB$/i);
|
||||
if (!preampMatch) {
|
||||
throw new Error("格式错误:第 1 行应为 Preamp:-5.96dB");
|
||||
throw new Error(eqInterp(t.errPreampFirstLine, {}));
|
||||
}
|
||||
const preamp = Number(preampMatch[1]);
|
||||
if (!Number.isFinite(preamp)) {
|
||||
throw new Error("格式错误:Preamp 数值无效");
|
||||
throw new Error(eqInterp(t.errPreampValue, {}));
|
||||
}
|
||||
|
||||
const parsedFilters = new Array<{
|
||||
@@ -824,24 +862,24 @@ export default function EQPage() {
|
||||
/^Filter\s+(\d+)\s*:\s*(ON|OFF)\s+([A-Za-z]+)\s+Fc\s+([+-]?\d+(?:\.\d+)?)\s+Hz\s+Gain\s+([+-]?\d+(?:\.\d+)?)\s+dB\s+Q\s+([+-]?\d+(?:\.\d+)?)$/i
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(`格式错误:第 ${i + 1} 行不符合 Filter 格式`);
|
||||
throw new Error(eqInterp(t.errFilterLine, { line: String(i + 1) }));
|
||||
}
|
||||
|
||||
const filterNo = Number(match[1]);
|
||||
if (filterNo < 1 || filterNo > 10) {
|
||||
throw new Error(`格式错误:Filter 编号 ${filterNo} 超出范围`);
|
||||
throw new Error(eqInterp(t.errFilterNoRange, { no: String(filterNo) }));
|
||||
}
|
||||
|
||||
const type = normalizeFilterType(match[3].toUpperCase());
|
||||
if (!FILTER_TYPES.includes(type)) {
|
||||
throw new Error(`格式错误:Filter ${filterNo} 的类型无效(${match[3]})`);
|
||||
throw new Error(eqInterp(t.errFilterType, { no: String(filterNo), type: match[3] }));
|
||||
}
|
||||
|
||||
const freq = Number(match[4]);
|
||||
const gain = Number(match[5]);
|
||||
const q = Number(match[6]);
|
||||
if (![freq, gain, q].every(Number.isFinite)) {
|
||||
throw new Error(`格式错误:Filter ${filterNo} 的数值无效`);
|
||||
throw new Error(eqInterp(t.errFilterNumbers, { no: String(filterNo) }));
|
||||
}
|
||||
|
||||
parsedFilters[filterNo - 1] = {
|
||||
@@ -854,14 +892,16 @@ export default function EQPage() {
|
||||
}
|
||||
|
||||
if (parsedFilters.some((item) => !item)) {
|
||||
throw new Error("格式错误:Filter 1-10 必须全部提供");
|
||||
throw new Error(eqInterp(t.errFilterAll, {}));
|
||||
}
|
||||
|
||||
return {
|
||||
preamp: Number(preamp.toFixed(2)),
|
||||
filters: parsedFilters as typeof bands,
|
||||
};
|
||||
};
|
||||
},
|
||||
[eqUi],
|
||||
);
|
||||
|
||||
const handleSaveBatchEdit = () => {
|
||||
try {
|
||||
@@ -884,9 +924,9 @@ export default function EQPage() {
|
||||
schedulePeqSync(updatedPeq, parsed.filters, 0);
|
||||
setSelectedBand(0);
|
||||
setIsBatchEditDialogOpen(false);
|
||||
toast.success("批量编辑已应用");
|
||||
toast.success(eqUi.toastBatchApplied);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "批量编辑格式错误");
|
||||
toast.error(error instanceof Error ? error.message : eqUi.toastBatchParseError);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -917,23 +957,23 @@ export default function EQPage() {
|
||||
const target = peqItems[headphoneIdx];
|
||||
if (!target?.name) return;
|
||||
|
||||
const confirmed = window.confirm(`是否删除耳机「${target.name}」?`);
|
||||
const confirmed = window.confirm(eqInterp(eqUi.deleteConfirm, { name: target.name }));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
if (isDemoMode || !api) {
|
||||
const nextItems = peqItems.filter((_, idx) => idx !== headphoneIdx);
|
||||
applyPeqStateToUI({ peq: nextItems, peqSelect: Math.max(0, headphoneIdx - 1) });
|
||||
toast.success("已删除耳机");
|
||||
toast.success(eqUi.toastDeleted);
|
||||
return;
|
||||
}
|
||||
|
||||
await api.removePeq([target.name]);
|
||||
const latest = await api.getPeqState();
|
||||
applyPeqStateToUI(latest as { peq?: Array<{ name: string; filters?: any[] | string }>; peqSelect?: number });
|
||||
toast.success("已删除耳机");
|
||||
toast.success(eqUi.toastDeleted);
|
||||
} catch {
|
||||
toast.error("删除耳机失败");
|
||||
toast.error(eqUi.toastDeleteFail);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1385,7 +1425,7 @@ export default function EQPage() {
|
||||
style={{ background: "rgba(0,255,246,0.12)", border: "1px solid rgba(0,255,246,0.2)" }}>
|
||||
<Edit3 size={20} style={{ color: "#00FFF6" }} />
|
||||
</div>
|
||||
<span className="text-[14px] text-white/80">批量编辑</span>
|
||||
<span className="text-[14px] text-white/80">{peqCardLabels.edit}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ios-list-group flex flex-col items-center justify-center py-3 gap-2 active:opacity-70 transition-opacity"
|
||||
@@ -1394,7 +1434,7 @@ export default function EQPage() {
|
||||
style={{ background: "rgba(0,255,246,0.12)", border: "1px solid rgba(0,255,246,0.2)" }}>
|
||||
<Headphones size={20} style={{ color: "#00FFF6" }} />
|
||||
</div>
|
||||
<span className="text-[14px] text-white/80">添加耳机型号</span>
|
||||
<span className="text-[14px] text-white/80">{peqCardLabels.headset}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1484,6 +1524,7 @@ export default function EQPage() {
|
||||
onCopyMode={copyModeParams}
|
||||
onBandDrag={(idx, patch) => setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b))}
|
||||
onBandSelect={setSelectedBand}
|
||||
eqUi={eqUi}
|
||||
/>
|
||||
|
||||
{/* ── Band grid (2 rows × 5) ── */}
|
||||
@@ -1513,7 +1554,7 @@ export default function EQPage() {
|
||||
<div className="ios-list-group p-4 space-y-5 relative z-30 overflow-visible">
|
||||
{/* Filter type selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[14px] text-white/50 font-medium">滤波器</span>
|
||||
<span className="text-[14px] text-white/50 font-medium">{peqCardLabels.filters?.label ?? "滤波器"}</span>
|
||||
<div
|
||||
ref={filterMenuRef}
|
||||
className="relative z-40 w-[136px] rounded-[12px] overflow-visible backdrop-blur-md transition-all"
|
||||
@@ -1596,10 +1637,10 @@ export default function EQPage() {
|
||||
{/* Q 值 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[13px] text-white/45 font-medium">Q 值</span>
|
||||
<span className="text-[13px] text-white/45 font-medium">{peqCardLabels.q ?? "Q值"}</span>
|
||||
<span className="text-[13px] font-semibold px-2.5 py-1 rounded-[8px] text-white"
|
||||
style={{ background: "rgba(44,44,46,0.9)", border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
{band.q.toFixed(2)} dB
|
||||
{band.q.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<CyanSlider value={band.q} min={0.1} max={10} step={0.01}
|
||||
@@ -1611,7 +1652,7 @@ export default function EQPage() {
|
||||
<div className="ios-list-group p-4 relative z-10">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-[13px] text-white/45">总增益</span>
|
||||
<span className="text-[13px] text-white/45">{peqCardLabels.preamp}</span>
|
||||
<span className="text-[28px] font-bold ml-2" style={{ color: "#00FFF6" }}>
|
||||
{preampValue >= 0 ? `+${preampValue.toFixed(1)}` : preampValue.toFixed(1)}
|
||||
</span>
|
||||
@@ -1719,7 +1760,7 @@ export default function EQPage() {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-[28px] text-white/90 font-semibold">批量编辑</h3>
|
||||
<h3 className="text-[28px] text-white/90 font-semibold">{eqUi.batchEditTitle}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-white/45 hover:text-white/75 transition-colors"
|
||||
@@ -1729,7 +1770,7 @@ export default function EQPage() {
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-3 text-[13px] text-white/55">
|
||||
共 11 行:第 1 行 Preamp,第 2-11 行 Filter 1-10。保存后将覆盖当前耳机参数。
|
||||
{eqUi.batchEditHint}
|
||||
</p>
|
||||
<textarea
|
||||
value={batchEditText}
|
||||
@@ -1743,14 +1784,14 @@ export default function EQPage() {
|
||||
className="px-5 py-2 rounded-full text-[24px] text-white/80 bg-[#3f4349] hover:bg-[#4a4f56] transition-colors"
|
||||
onClick={() => setIsBatchEditDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
{eqUi.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-5 py-2 rounded-full text-[24px] text-black bg-[#00FFF6] hover:brightness-95 transition-all"
|
||||
onClick={handleSaveBatchEdit}
|
||||
>
|
||||
保存
|
||||
{eqUi.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1762,7 +1803,7 @@ export default function EQPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/55"
|
||||
aria-label="关闭"
|
||||
aria-label={eqUi.closeDrawer}
|
||||
onClick={() => setIsBrandDrawerOpen(false)}
|
||||
/>
|
||||
<div
|
||||
@@ -1775,7 +1816,8 @@ export default function EQPage() {
|
||||
<div className="flex shrink-0 items-stretch rounded-t-[12px] bg-[#2c2c2e] px-1 pt-1">
|
||||
{(["brands", "models", "target"] as const).map((tab) => {
|
||||
const active = brandDrawerTab === tab;
|
||||
const label = tab === "brands" ? "Brands" : tab === "models" ? "Models" : "Target";
|
||||
const label =
|
||||
tab === "brands" ? eqUi.tabBrands : tab === "models" ? eqUi.tabModels : eqUi.tabTarget;
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
@@ -1801,14 +1843,14 @@ export default function EQPage() {
|
||||
type="search"
|
||||
value={brandSearchQuery}
|
||||
onChange={(e) => setBrandSearchQuery(e.target.value)}
|
||||
placeholder="搜索品牌或型号"
|
||||
placeholder={eqUi.searchPlaceholder}
|
||||
className="min-w-0 flex-1 bg-transparent text-[15px] text-black/80 outline-none placeholder:text-black/35"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
|
||||
{brandSearchQuery.trim() && catalogSearchLoading && (
|
||||
<div className="py-10 text-center text-[14px] text-black/45">搜索中…</div>
|
||||
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.searching}</div>
|
||||
)}
|
||||
{!!brandSearchQuery.trim() && !catalogSearchLoading && catalogSearchError && (
|
||||
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogSearchError}</div>
|
||||
@@ -1817,7 +1859,7 @@ export default function EQPage() {
|
||||
!catalogSearchLoading &&
|
||||
!catalogSearchError &&
|
||||
catalogSearchResults.length === 0 && (
|
||||
<div className="py-10 text-center text-[14px] text-black/45">未找到相关品牌或型号</div>
|
||||
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noSearchResults}</div>
|
||||
)}
|
||||
{!!brandSearchQuery.trim() &&
|
||||
!catalogSearchLoading &&
|
||||
@@ -1844,13 +1886,13 @@ export default function EQPage() {
|
||||
</button>
|
||||
))}
|
||||
{!brandSearchQuery.trim() && catalogBrandsLoading && (
|
||||
<div className="py-10 text-center text-[14px] text-black/45">加载中…</div>
|
||||
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.loading}</div>
|
||||
)}
|
||||
{!brandSearchQuery.trim() && !catalogBrandsLoading && catalogBrandsError && (
|
||||
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogBrandsError}</div>
|
||||
)}
|
||||
{!brandSearchQuery.trim() && !catalogBrandsLoading && !catalogBrandsError && filteredCatalogBrands.length === 0 && (
|
||||
<div className="py-10 text-center text-[14px] text-black/45">无匹配品牌</div>
|
||||
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noBrandMatch}</div>
|
||||
)}
|
||||
{!brandSearchQuery.trim() &&
|
||||
!catalogBrandsLoading &&
|
||||
@@ -1880,22 +1922,25 @@ export default function EQPage() {
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
|
||||
<div className="sticky top-0 z-10 border-b border-black/8 bg-white px-4 py-2.5 text-[13px] text-black/55">
|
||||
品牌:<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span>
|
||||
{eqUi.brandLabel}<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span>
|
||||
{selectedCatalogModelFromSearch && (
|
||||
<span className="ml-2 text-black/45">已选搜索型号:{selectedCatalogModelFromSearch}</span>
|
||||
<span className="ml-2 text-black/45">
|
||||
{eqUi.modelFromSearchLabel}
|
||||
{selectedCatalogModelFromSearch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!selectedCatalogBrand && (
|
||||
<div className="py-10 text-center text-[14px] text-black/45">请先在 Brands 中选择品牌</div>
|
||||
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.selectBrandFirst}</div>
|
||||
)}
|
||||
{!!selectedCatalogBrand && catalogModelsLoading && (
|
||||
<div className="py-10 text-center text-[14px] text-black/45">加载中…</div>
|
||||
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.loading}</div>
|
||||
)}
|
||||
{!!selectedCatalogBrand && !catalogModelsLoading && catalogModelsError && (
|
||||
<div className="px-4 py-8 text-center text-[14px] text-red-600/90">{catalogModelsError}</div>
|
||||
)}
|
||||
{!!selectedCatalogBrand && !catalogModelsLoading && !catalogModelsError && catalogModels.length === 0 && (
|
||||
<div className="py-10 text-center text-[14px] text-black/45">该品牌暂无型号</div>
|
||||
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noModelsForBrand}</div>
|
||||
)}
|
||||
{!!selectedCatalogBrand &&
|
||||
!catalogModelsLoading &&
|
||||
@@ -1923,9 +1968,18 @@ export default function EQPage() {
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y">
|
||||
<div className="sticky top-0 z-10 border-b border-black/8 bg-white px-4 py-2.5 text-[13px] text-black/55 space-y-0.5">
|
||||
<div>品牌:<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span></div>
|
||||
<div>型号:<span className="font-semibold text-black/80">{selectedCatalogModelName || "—"}</span></div>
|
||||
<div>Form:<span className="font-semibold text-black/80">{selectedCatalogModelForm || "全部"}</span></div>
|
||||
<div>
|
||||
{eqUi.targetBrand}
|
||||
<span className="font-semibold text-black/80">{selectedCatalogBrand || "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
{eqUi.targetModel}
|
||||
<span className="font-semibold text-black/80">{selectedCatalogModelName || "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
{eqUi.targetForm}
|
||||
<span className="font-semibold text-black/80">{selectedCatalogModelForm || eqUi.formAll}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pt-4 pb-3">
|
||||
<button
|
||||
@@ -2024,9 +2078,9 @@ export default function EQPage() {
|
||||
}
|
||||
|
||||
setIsBrandDrawerOpen(false);
|
||||
toast.success("新耳机 EQ 已上报");
|
||||
toast.success(eqUi.toastNewEqOk);
|
||||
} catch {
|
||||
toast.error("获取曲线失败");
|
||||
toast.error(eqUi.toastCurveFail);
|
||||
} finally {
|
||||
setIsConfirmingTarget(false);
|
||||
}
|
||||
@@ -2039,14 +2093,14 @@ export default function EQPage() {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{isConfirmingTarget ? "loading..." : "confirm"}
|
||||
{isConfirmingTarget ? eqUi.confirmLoading : eqUi.confirmButton}
|
||||
</button>
|
||||
</div>
|
||||
{!selectedCatalogModelName && (
|
||||
<div className="py-10 text-center text-[14px] text-black/45">请先选择型号</div>
|
||||
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.selectModelFirst}</div>
|
||||
)}
|
||||
{!!selectedCatalogModelName && availableCatalogTargets.length === 0 && (
|
||||
<div className="py-10 text-center text-[14px] text-black/45">暂无可用 Target</div>
|
||||
<div className="py-10 text-center text-[14px] text-black/45">{eqUi.noTargets}</div>
|
||||
)}
|
||||
{!!selectedCatalogModelName &&
|
||||
availableCatalogTargets.map((target) => {
|
||||
@@ -2059,12 +2113,16 @@ export default function EQPage() {
|
||||
style={active ? { background: "rgba(0,255,246,0.14)" } : undefined}
|
||||
onClick={() => {
|
||||
setSelectedCatalogTarget(target.name);
|
||||
toast.success(`已选 Target:${target.name}`);
|
||||
toast.success(eqInterp(eqUi.toastTargetSelected, { name: target.name }));
|
||||
}}
|
||||
>
|
||||
<div className="text-[15px] text-black/85 font-medium">{target.name}</div>
|
||||
<div className="mt-1 text-[12px] text-black/55">
|
||||
Bass boost: fc {target.bassBoost.fc}, q {target.bassBoost.q}, gain {target.bassBoost.gain}dB
|
||||
{eqInterp(eqUi.bassBoost, {
|
||||
fc: String(target.bassBoost.fc),
|
||||
q: String(target.bassBoost.q),
|
||||
gain: String(target.bassBoost.gain),
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
+125
-38
@@ -2,12 +2,12 @@
|
||||
HOME — Luxsin X9 Controller (Main Dashboard)
|
||||
Design: Reference luxsin_x8_首页.png
|
||||
Layout (top to bottom):
|
||||
1. TopBar: device name + settings icon
|
||||
1. Device area
|
||||
2. Device status card (运行中/待机/充电 + 电量/模式/环保参数)
|
||||
3. Device render image (hardware photo)
|
||||
4. Volume slider with dB display
|
||||
5. 4 quick-action circle buttons (电源/EQ/HP-EQ/Bypass)
|
||||
6. iOS list rows (输入源/输出端口/Effect/音频设置/VU表)
|
||||
5. 4 quick-action circle buttons (电源/HP-EQ/Effect/Bypass;HP-EQ/Effect 支持双击开关)
|
||||
6. iOS list rows (输入源/输出端口/音频设置/系统设置/VU表)
|
||||
============================================================ */
|
||||
import { useDevice } from "@/contexts/DeviceContext";
|
||||
import ConnectScreen from "@/components/ConnectScreen";
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ChevronRight,
|
||||
Power,
|
||||
Settings,
|
||||
Settings2,
|
||||
Sliders,
|
||||
Activity,
|
||||
Gauge,
|
||||
@@ -100,6 +101,10 @@ export default function Home() {
|
||||
const { isConnected, deviceState, setVolume, updateSetting, api, disconnect, isDemoMode } = useDevice();
|
||||
const [, setLocation] = useLocation();
|
||||
const powerActionRef = useRef(false);
|
||||
const hpEqLastTapRef = useRef<number | null>(null);
|
||||
const hpEqNavigateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const effectLastTapRef = useRef<number | null>(null);
|
||||
const effectNavigateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [powerConfirmOpen, setPowerConfirmOpen] = useState(false);
|
||||
|
||||
const homeText = useMemo((): HomeLocale => {
|
||||
@@ -112,6 +117,7 @@ export default function Home() {
|
||||
const [localVol, setLocalVol] = useState(deviceState?.volume ?? 100);
|
||||
const isDragging = useRef(false);
|
||||
const knobDraggingRef = useRef(false);
|
||||
const knobActivePointerIdRef = useRef<number | null>(null);
|
||||
const knobStartAngleRef = useRef(0);
|
||||
const knobStartVolRef = useRef(0);
|
||||
const knobLastVolRef = useRef<number>(localVol);
|
||||
@@ -126,12 +132,6 @@ export default function Home() {
|
||||
knobLastVolRef.current = localVol;
|
||||
}, [localVol]);
|
||||
|
||||
const handleVolChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = Number(e.target.value);
|
||||
setLocalVol(v);
|
||||
setVolume(v);
|
||||
}, [setVolume]);
|
||||
|
||||
const handlePowerOff = useCallback(async () => {
|
||||
if (powerActionRef.current) return;
|
||||
powerActionRef.current = true;
|
||||
@@ -155,13 +155,66 @@ export default function Home() {
|
||||
}
|
||||
}, [api, disconnect, isDemoMode, homeText]);
|
||||
|
||||
/** HP-EQ 圆钮:单击进入 /eq;双击在 280ms 内第二次点击则切换 peqEnable(与 info.cgi?action=setting&peqEnable=0|1 一致) */
|
||||
const HP_EQ_DOUBLE_CLICK_MS = 280;
|
||||
const handleHpEqCircleClick = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const last = hpEqLastTapRef.current;
|
||||
if (last !== null && now - last < HP_EQ_DOUBLE_CLICK_MS) {
|
||||
hpEqLastTapRef.current = null;
|
||||
if (hpEqNavigateTimerRef.current) {
|
||||
clearTimeout(hpEqNavigateTimerRef.current);
|
||||
hpEqNavigateTimerRef.current = null;
|
||||
}
|
||||
const on = (deviceState?.peqEnable ?? 0) === 1;
|
||||
void updateSetting({ peqEnable: on ? 0 : 1 });
|
||||
return;
|
||||
}
|
||||
hpEqLastTapRef.current = now;
|
||||
if (hpEqNavigateTimerRef.current) clearTimeout(hpEqNavigateTimerRef.current);
|
||||
hpEqNavigateTimerRef.current = setTimeout(() => {
|
||||
hpEqNavigateTimerRef.current = null;
|
||||
hpEqLastTapRef.current = null;
|
||||
setLocation("/eq");
|
||||
}, HP_EQ_DOUBLE_CLICK_MS);
|
||||
}, [deviceState?.peqEnable, updateSetting, setLocation]);
|
||||
|
||||
/** Effect 圆钮:单击进入 /effects;双击切换 audio_enable */
|
||||
const handleEffectCircleClick = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const last = effectLastTapRef.current;
|
||||
if (last !== null && now - last < HP_EQ_DOUBLE_CLICK_MS) {
|
||||
effectLastTapRef.current = null;
|
||||
if (effectNavigateTimerRef.current) {
|
||||
clearTimeout(effectNavigateTimerRef.current);
|
||||
effectNavigateTimerRef.current = null;
|
||||
}
|
||||
const on = (deviceState?.audio_enable ?? 0) === 1;
|
||||
void updateSetting({ audio_enable: on ? 0 : 1 });
|
||||
return;
|
||||
}
|
||||
effectLastTapRef.current = now;
|
||||
if (effectNavigateTimerRef.current) clearTimeout(effectNavigateTimerRef.current);
|
||||
effectNavigateTimerRef.current = setTimeout(() => {
|
||||
effectNavigateTimerRef.current = null;
|
||||
effectLastTapRef.current = null;
|
||||
setLocation("/effects");
|
||||
}, HP_EQ_DOUBLE_CLICK_MS);
|
||||
}, [deviceState?.audio_enable, updateSetting, setLocation]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hpEqNavigateTimerRef.current) clearTimeout(hpEqNavigateTimerRef.current);
|
||||
if (effectNavigateTimerRef.current) clearTimeout(effectNavigateTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isConnected) return <ConnectScreen />;
|
||||
|
||||
const ds = deviceState!;
|
||||
const inputLabel = INPUT_LABELS[ds.input] ?? "USB-C";
|
||||
const outputLabel = OUTPUT_LABELS[ds.output] ?? "Headset";
|
||||
const effectOn = ds.audio_enable === 1;
|
||||
const audioOn = ds.audio_enable === 1;
|
||||
const vuLabel = `${homeText.vu ?? "VU表"}${(ds.vu ?? 0) + 1}`;
|
||||
const bypassOn = (ds.dsp_enable ?? 0) === 0;
|
||||
|
||||
@@ -194,14 +247,16 @@ export default function Home() {
|
||||
const el = e.currentTarget;
|
||||
const rect = el.getBoundingClientRect();
|
||||
knobDraggingRef.current = true;
|
||||
knobActivePointerIdRef.current = e.pointerId;
|
||||
knobStartAngleRef.current = pointAngle(e.clientX, e.clientY, rect);
|
||||
knobStartVolRef.current = knobLastVolRef.current;
|
||||
isDragging.current = true;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
/** 拖动中只更新本地 UI,不调用 setVolume(避免来回旋时频繁打接口) */
|
||||
const moveKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!knobDraggingRef.current) return;
|
||||
if (!knobDraggingRef.current || knobActivePointerIdRef.current !== e.pointerId) return;
|
||||
const el = e.currentTarget;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const a = pointAngle(e.clientX, e.clientY, rect);
|
||||
@@ -210,13 +265,15 @@ export default function Home() {
|
||||
const stepsPerRad = 200 / (Math.PI * 1.5);
|
||||
const next = Math.max(0, Math.min(200, Math.round(knobStartVolRef.current + delta * stepsPerRad)));
|
||||
setLocalVol(next);
|
||||
setVolume(next);
|
||||
knobLastVolRef.current = next;
|
||||
};
|
||||
|
||||
const endKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!knobDraggingRef.current) return;
|
||||
const commitKnobDragEnd = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!knobDraggingRef.current || knobActivePointerIdRef.current !== e.pointerId) return;
|
||||
knobDraggingRef.current = false;
|
||||
knobActivePointerIdRef.current = null;
|
||||
isDragging.current = false;
|
||||
setVolume(knobLastVolRef.current);
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
@@ -224,17 +281,18 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const endKnobDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
commitKnobDragEnd(e);
|
||||
};
|
||||
|
||||
/** 异常丢失 capture 时仍提交一次,避免只松手未触发 pointerup 时漏提交 */
|
||||
const lostKnobPointerCapture = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!knobDraggingRef.current || knobActivePointerIdRef.current !== e.pointerId) return;
|
||||
commitKnobDragEnd(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex flex-col">
|
||||
{/* ── Top bar ── */}
|
||||
<div className="flex items-center justify-end px-4 pt-4 pb-2">
|
||||
<button onClick={() => setLocation("/system")}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
|
||||
style={{ background: "rgba(44,44,46,0.6)" }}>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Device render image ── */}
|
||||
<div className="px-4 pb-4">
|
||||
<div className="w-full rounded-2xl overflow-hidden flex items-center justify-center"
|
||||
@@ -322,6 +380,7 @@ export default function Home() {
|
||||
onPointerMove={moveKnobDrag}
|
||||
onPointerUp={endKnobDrag}
|
||||
onPointerCancel={endKnobDrag}
|
||||
onLostPointerCapture={lostKnobPointerCapture}
|
||||
>
|
||||
<div className="w-1 h-4 rounded-full bg-white/40" style={{ transform: "rotate(-30deg)", transformOrigin: "bottom center" }} />
|
||||
</div>
|
||||
@@ -357,11 +416,40 @@ export default function Home() {
|
||||
<input
|
||||
type="range" min={0} max={200} value={localVol}
|
||||
className="cyan-slider relative z-10"
|
||||
onMouseDown={() => { isDragging.current = true; }}
|
||||
onMouseUp={() => { isDragging.current = false; }}
|
||||
onTouchStart={() => { isDragging.current = true; }}
|
||||
onTouchEnd={() => { isDragging.current = false; }}
|
||||
onChange={handleVolChange}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setLocalVol(v);
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
isDragging.current = true;
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
isDragging.current = false;
|
||||
setVolume(Number(e.currentTarget.value));
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
isDragging.current = false;
|
||||
setVolume(Number(e.currentTarget.value));
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
isDragging.current = false;
|
||||
setVolume(Number(e.currentTarget.value));
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
const k = e.key;
|
||||
if (
|
||||
k === "ArrowLeft" ||
|
||||
k === "ArrowRight" ||
|
||||
k === "ArrowUp" ||
|
||||
k === "ArrowDown" ||
|
||||
k === "Home" ||
|
||||
k === "End" ||
|
||||
k === "PageUp" ||
|
||||
k === "PageDown"
|
||||
) {
|
||||
setVolume(Number(e.currentTarget.value));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -480,8 +568,9 @@ export default function Home() {
|
||||
{/* EQ */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn("icon-circle-btn", !bypassOn && ds.peqEnable === 1 && "active")}
|
||||
onClick={() => setLocation("/eq")}
|
||||
onClick={handleHpEqCircleClick}
|
||||
>
|
||||
<Sliders size={22} className={(!bypassOn && ds.peqEnable === 1) ? "text-[#00FFF6]" : "text-white/60"} />
|
||||
</button>
|
||||
@@ -491,8 +580,9 @@ export default function Home() {
|
||||
{/* Effect */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn("icon-circle-btn", !bypassOn && effectOn && "active")}
|
||||
onClick={() => setLocation("/effects")}
|
||||
onClick={handleEffectCircleClick}
|
||||
>
|
||||
<Activity size={22} className={(!bypassOn && effectOn) ? "text-[#00FFF6]" : "text-white/60"} />
|
||||
</button>
|
||||
@@ -531,19 +621,16 @@ export default function Home() {
|
||||
value={outputLabel}
|
||||
onClick={() => setLocation("/io")}
|
||||
/>
|
||||
<ListRow
|
||||
icon={<Sliders size={16} />}
|
||||
label="Effect"
|
||||
value={effectOn ? "已开启" : undefined}
|
||||
toggle
|
||||
checked={effectOn}
|
||||
onToggle={(v) => updateSetting({ audio_enable: v ? 1 : 0 })}
|
||||
/>
|
||||
<ListRow
|
||||
icon={<Settings size={16} />}
|
||||
label={homeText.audioSet ?? "音频设置"}
|
||||
onClick={() => setLocation("/audio")}
|
||||
/>
|
||||
<ListRow
|
||||
icon={<Settings2 size={16} />}
|
||||
label={homeText.systemSet ?? "系统设置"}
|
||||
onClick={() => setLocation("/system")}
|
||||
/>
|
||||
<ListRow
|
||||
icon={<Gauge size={16} />}
|
||||
label={homeText.vu ?? "VU表"}
|
||||
|
||||
+35
-18
@@ -1,7 +1,7 @@
|
||||
/* ============================================================
|
||||
VU PAGE — VU Meter Gallery
|
||||
Design: Reference luxsin_x8_VU表.png
|
||||
- Card-based VU meter style selector
|
||||
- 3-column grid of VU style cards
|
||||
- Active = cyan check circle below card
|
||||
- Inactive = empty circle
|
||||
============================================================ */
|
||||
@@ -10,6 +10,12 @@ import { cn } from "@/lib/utils";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
import { Check, ChevronLeft } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useMemo } from "react";
|
||||
import localeZh from "@/locales/data-zh.json";
|
||||
import localeZhHK from "@/locales/data-zh-HK.json";
|
||||
import localeEn from "@/locales/data-en.json";
|
||||
|
||||
type VUPageLocale = NonNullable<(typeof localeZh)["vuPage"]>;
|
||||
|
||||
interface VUStyle {
|
||||
index: number;
|
||||
@@ -28,62 +34,73 @@ export default function VUPage() {
|
||||
const { deviceState, updateSetting } = useDevice();
|
||||
const currentVU = deviceState?.vu ?? 0;
|
||||
|
||||
const vuPageText = useMemo((): VUPageLocale => {
|
||||
const lang = deviceState?.language;
|
||||
const loc =
|
||||
lang === 0 ? localeEn : lang === 1 ? localeZhHK : localeZh;
|
||||
return (loc.vuPage ?? {}) as VUPageLocale;
|
||||
}, [deviceState?.language]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black">
|
||||
<div className="page-header">
|
||||
<button onClick={() => setLocation("/")} 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">VU 表库</h1>
|
||||
<h1 className="flex-1 text-center text-[17px] font-semibold text-white">
|
||||
{vuPageText.title ?? "VU 表库"}
|
||||
</h1>
|
||||
<div className="w-8" />
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-32 pt-4 space-y-5">
|
||||
<div className="px-4 pb-32 pt-4">
|
||||
<div className="grid grid-cols-3 gap-x-2 gap-y-4">
|
||||
{VU_STYLES.map((style) => {
|
||||
const isActive = currentVU === style.index;
|
||||
return (
|
||||
<div key={style.index} className="space-y-3">
|
||||
{/* Card */}
|
||||
<div key={style.index} className="flex flex-col items-center gap-1.5 min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
"ios-list-group p-3 cursor-pointer transition-all duration-200",
|
||||
"ios-list-group p-1.5 cursor-pointer transition-all duration-200 w-full",
|
||||
isActive && "border-[#00FFF6]/30"
|
||||
)}
|
||||
style={isActive ? { borderColor: "rgba(0,255,246,0.3)", boxShadow: "0 0 20px rgba(0,255,246,0.08)" } : {}}
|
||||
style={isActive ? { borderColor: "rgba(0,255,246,0.3)", boxShadow: "0 0 16px rgba(0,255,246,0.08)" } : {}}
|
||||
onClick={() => updateSetting({ vu: style.index })}
|
||||
>
|
||||
<img
|
||||
src={style.preview}
|
||||
alt={style.name}
|
||||
className="w-full h-28 object-contain rounded-xl bg-black/20"
|
||||
className="w-full h-[4.5rem] sm:h-24 object-contain rounded-lg bg-black/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selection indicator */}
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateSetting({ vu: style.index })}
|
||||
className={cn(
|
||||
"w-7 h-7 rounded-full flex items-center justify-center transition-all duration-200",
|
||||
"w-6 h-6 rounded-full flex items-center justify-center transition-all duration-200 shrink-0",
|
||||
isActive
|
||||
? "bg-[#00FFF6] text-black shadow-[0_0_12px_rgba(0,255,246,0.5)]"
|
||||
? "bg-[#00FFF6] text-black shadow-[0_0_10px_rgba(0,255,246,0.45)]"
|
||||
: "border-2 border-white/20 text-transparent"
|
||||
)}
|
||||
aria-label={isActive ? `${style.name}(已选)` : `选择${style.name}`}
|
||||
>
|
||||
{isActive && <Check size={14} strokeWidth={3} />}
|
||||
{isActive && <Check size={12} strokeWidth={3} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Style name */}
|
||||
<div className="text-center">
|
||||
<p className={cn("text-[15px] font-medium", isActive ? "text-[#00FFF6]" : "text-white/60")}>
|
||||
<p
|
||||
className={cn(
|
||||
"text-center text-[11px] sm:text-xs font-medium truncate w-full px-0.5",
|
||||
isActive ? "text-[#00FFF6]" : "text-white/60"
|
||||
)}
|
||||
>
|
||||
{style.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user