提交应用EQ时判断EQ是否启用和删除不生效的问题
This commit is contained in:
+43
-105
@@ -28,8 +28,11 @@ export function decodeCustomBase64(encoded: string): string {
|
|||||||
const index = ALPHABET_CUSTOM.indexOf(char);
|
const index = ALPHABET_CUSTOM.indexOf(char);
|
||||||
translated += index !== -1 ? ALPHABET_STANDARD.charAt(index) : char;
|
translated += index !== -1 ? ALPHABET_STANDARD.charAt(index) : char;
|
||||||
}
|
}
|
||||||
// Chrome 91 atob() is stricter than modern browsers.
|
// Strip whitespace / newlines that may be present in API responses.
|
||||||
|
// Chrome 91 atob() is stricter than modern browsers and rejects these.
|
||||||
translated = translated.replace(/\s/g, "");
|
translated = translated.replace(/\s/g, "");
|
||||||
|
// Ensure correct Base64 padding (=). Modern atob() tolerates missing padding,
|
||||||
|
// but Chrome 91 throws "The string to be decoded is not correctly encoded".
|
||||||
const padNeeded = (4 - (translated.length % 4)) % 4;
|
const padNeeded = (4 - (translated.length % 4)) % 4;
|
||||||
if (padNeeded > 0) {
|
if (padNeeded > 0) {
|
||||||
translated += "=".repeat(padNeeded);
|
translated += "=".repeat(padNeeded);
|
||||||
@@ -95,6 +98,7 @@ export interface DeviceState {
|
|||||||
dacGain: number;
|
dacGain: number;
|
||||||
dacArc: number;
|
dacArc: number;
|
||||||
dacImpedance: number;
|
dacImpedance: number;
|
||||||
|
dreMode: number;
|
||||||
dacVolumeDirect: number;
|
dacVolumeDirect: number;
|
||||||
analogGain: number;
|
analogGain: number;
|
||||||
effect_enable: number;
|
effect_enable: number;
|
||||||
@@ -109,56 +113,19 @@ export interface DeviceState {
|
|||||||
subwoofer_value: number;
|
subwoofer_value: number;
|
||||||
subwoofer_rate: number;
|
subwoofer_rate: number;
|
||||||
subwoofer_gain: number;
|
subwoofer_gain: number;
|
||||||
subwoofer_mix_type: number;
|
|
||||||
subwoofer_delay: number;
|
|
||||||
subwoofer_delay_main: number;
|
|
||||||
subwoofer_delay_r: number;
|
|
||||||
subwoofer_delay_main_r: number;
|
|
||||||
subwoofer_lpf_enable: number;
|
|
||||||
subwoofer_hpf_enable: number;
|
|
||||||
loudness_enable: number;
|
loudness_enable: number;
|
||||||
loudness_bass_gain: number;
|
loudness_bass_gain: number;
|
||||||
loudness_treble_gain: number;
|
loudness_treble_gain: number;
|
||||||
loudness_threshold_gain: number;
|
loudness_threshold_gain: number;
|
||||||
hearing_enable: number;
|
|
||||||
hearing_select: number;
|
|
||||||
hearing_data: HearingProfile[] | string;
|
|
||||||
bt_status: number;
|
bt_status: number;
|
||||||
bt_srcname: string;
|
bt_srcname: string;
|
||||||
bt_title: string;
|
bt_title: string;
|
||||||
bt_artist: string;
|
bt_artist: string;
|
||||||
msgCount: number;
|
msgCount: number;
|
||||||
}
|
led_enable: number;
|
||||||
|
led_red: number;
|
||||||
export interface HearingProfile {
|
led_green: number;
|
||||||
n: string;
|
led_blue: number;
|
||||||
l: number[];
|
|
||||||
r: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse `hearing_data` from syncData (array or JSON string). */
|
|
||||||
export function parseHearingData(raw: unknown): HearingProfile[] {
|
|
||||||
if (raw == null || raw === "") return [];
|
|
||||||
let value: unknown = raw;
|
|
||||||
if (typeof raw === "string") {
|
|
||||||
try {
|
|
||||||
value = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
return value
|
|
||||||
.map((item) => {
|
|
||||||
if (!item || typeof item !== "object") return null;
|
|
||||||
const row = item as Record<string, unknown>;
|
|
||||||
const n = typeof row.n === "string" ? row.n.trim() : String(row.n ?? "").trim();
|
|
||||||
if (!n) return null;
|
|
||||||
const l = Array.isArray(row.l) ? row.l.map((v) => Number(v)) : [];
|
|
||||||
const r = Array.isArray(row.r) ? row.r.map((v) => Number(v)) : [];
|
|
||||||
return { n, l, r };
|
|
||||||
})
|
|
||||||
.filter((item): item is HearingProfile => item !== null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PeqFilter {
|
export interface PeqFilter {
|
||||||
@@ -171,6 +138,7 @@ export interface PeqFilter {
|
|||||||
export interface PeqState {
|
export interface PeqState {
|
||||||
filters: PeqFilter[];
|
filters: PeqFilter[];
|
||||||
peqSelect?: number;
|
peqSelect?: number;
|
||||||
|
peqEnable?: number; // 0: off, 1: on
|
||||||
peq?: Array<{
|
peq?: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
filters?: PeqFilter[] | string;
|
filters?: PeqFilter[] | string;
|
||||||
@@ -242,14 +210,8 @@ export interface PeqApplyPayload {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// Input/Output Labels
|
// Input/Output Labels
|
||||||
// ============================================================
|
// ============================================================
|
||||||
export const INPUT_LABELS = ["USB", "USB-C", "Coaxial", "Optical", "Bluetooth", "HDMI-EARC", "RCA"];
|
export const INPUT_LABELS = ["USB", "USB-C", "Coaxial", "Optical", "Bluetooth", "IIS", "RCA"];
|
||||||
export const OUTPUT_LABELS = ["XLR", "RCA", "Headset", "XLR/RCA"];
|
export const OUTPUT_LABELS = ["XLR", "RCA", "Headset", "XLR/RCA"];
|
||||||
|
|
||||||
/** Device `input` index for Bluetooth source. */
|
|
||||||
export const INPUT_BLUETOOTH_INDEX = 4;
|
|
||||||
|
|
||||||
/** Device `output` index for headphone / headset. */
|
|
||||||
export const OUTPUT_HEADSET_INDEX = 2;
|
|
||||||
export const LANGUAGE_LABELS = ["English", "繁體中文", "简体中文"];
|
export const LANGUAGE_LABELS = ["English", "繁體中文", "简体中文"];
|
||||||
export const SCREEN_LIGHT_LABELS = ["Bright", "Medium", "Dark"];
|
export const SCREEN_LIGHT_LABELS = ["Bright", "Medium", "Dark"];
|
||||||
export const KNOB_LIGHT_LABELS = ["Off", "Bright", "Medium", "Dark"];
|
export const KNOB_LIGHT_LABELS = ["Off", "Bright", "Medium", "Dark"];
|
||||||
@@ -301,7 +263,7 @@ export class LuxsinAPI {
|
|||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
const decoded = decodeCustomBase64(text.trim());
|
const decoded = decodeCustomBase64(text.trim());
|
||||||
const state = JSON.parse(decoded) as DeviceState;
|
const state = JSON.parse(decoded) as DeviceState;
|
||||||
console.log("[syncData] decoded", state);
|
console.log("[syncData]", state);
|
||||||
return state;
|
return state;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.maybeRedirectForHttpsCert(error);
|
this.maybeRedirectForHttpsCert(error);
|
||||||
@@ -342,29 +304,21 @@ export class LuxsinAPI {
|
|||||||
await this.postPeqJson(body);
|
await this.postPeqJson(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply current EQ filters (e.g. A/B comparison curve) without saving preset metadata. */
|
/** Apply current EQ filters (e.g. A/B curve switch) without a full preset save. */
|
||||||
async upgradePeqApply(body: PeqApplyPayload): Promise<void> {
|
async upgradePeqApply(body: PeqApplyPayload): Promise<void> {
|
||||||
await this.postPeqJson(body);
|
await this.postPeqJson(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST `json=<custom-base64>` — matches legacy axios `upgradePeq`. */
|
/** POST `json=<custom-base64>` — matches legacy axios `upgradePeq`. */
|
||||||
private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise<void> {
|
private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise<void> {
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
const action =
|
|
||||||
"peqChange" in body ? "peqChange" : "peqApply" in body ? "peqApply" : "peq";
|
|
||||||
console.log(`[dev/info.cgi] POST ${action}`, body);
|
|
||||||
}
|
|
||||||
const encoded = encodeCustomBase64(JSON.stringify(body));
|
const encoded = encodeCustomBase64(JSON.stringify(body));
|
||||||
const form = new URLSearchParams();
|
const form = new URLSearchParams();
|
||||||
form.set("json", encoded);
|
form.set("json", encoded);
|
||||||
const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
||||||
body: form.toString(),
|
body: form.toString(),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`PEQ request failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove one or more headphone PEQ profiles. */
|
/** Remove one or more headphone PEQ profiles. */
|
||||||
@@ -372,14 +326,11 @@ export class LuxsinAPI {
|
|||||||
const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names }));
|
const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names }));
|
||||||
const form = new URLSearchParams();
|
const form = new URLSearchParams();
|
||||||
form.set("json", encoded);
|
form.set("json", encoded);
|
||||||
const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
||||||
body: form.toString(),
|
body: form.toString(),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`PEQ remove failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setVolume(volume: number) { return this.setSetting({ volume }); }
|
setVolume(volume: number) { return this.setSetting({ volume }); }
|
||||||
@@ -409,7 +360,7 @@ export class LuxsinAPI {
|
|||||||
setCrossfeedEnable(enable: boolean) { return this.setSetting({ crossfeed_enable: enable ? 1 : 0 }); }
|
setCrossfeedEnable(enable: boolean) { return this.setSetting({ crossfeed_enable: enable ? 1 : 0 }); }
|
||||||
setXlrPolarity(reverse: boolean) { return this.setSetting({ xlr: reverse ? 1 : 0 }); }
|
setXlrPolarity(reverse: boolean) { return this.setSetting({ xlr: reverse ? 1 : 0 }); }
|
||||||
setDacArc(earc: boolean) { return this.setSetting({ dacArc: earc ? 1 : 0 }); }
|
setDacArc(earc: boolean) { return this.setSetting({ dacArc: earc ? 1 : 0 }); }
|
||||||
/** Boot volume level: 0 = default, 1..6 = -5..-30 dB, 7..10 = -35..-50 dB (see firmwareFeatures.bootSoundExtendedSteps). */
|
/** Boot volume level: 0 = default, 1..6 = -5..-30 dB, 7..10 = -35..-50 dB (firmware >= 26). */
|
||||||
setBootSound(level: number) {
|
setBootSound(level: number) {
|
||||||
return this.setSetting({ bootSound: Math.max(0, Math.min(10, Math.round(level))) });
|
return this.setSetting({ bootSound: Math.max(0, Math.min(10, Math.round(level))) });
|
||||||
}
|
}
|
||||||
@@ -417,7 +368,7 @@ export class LuxsinAPI {
|
|||||||
powerOff() { return this.setSetting({ power: 0 }); }
|
powerOff() { return this.setSetting({ power: 0 }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse firmware `version` for feature gating (see `config/firmwareFeatures.ts`). */
|
/** Parse firmware `version` for feature gating (e.g. bootSound extended steps). */
|
||||||
export function parseFirmwareVersion(version: unknown): number {
|
export function parseFirmwareVersion(version: unknown): number {
|
||||||
if (version === null || version === undefined) return 0;
|
if (version === null || version === undefined) return 0;
|
||||||
if (typeof version === "number" && Number.isFinite(version)) return version;
|
if (typeof version === "number" && Number.isFinite(version)) return version;
|
||||||
@@ -430,34 +381,32 @@ export function parseFirmwareVersion(version: unknown): number {
|
|||||||
return Number(parts[parts.length - 1]);
|
return Number(parts[parts.length - 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Display firmware version as X.Y.0.Z (e.g. 2005 → 2.0.0.5). */
|
/** UI display: build number 26 → `1.0.0.26`. Already-prefixed values are unchanged. */
|
||||||
export function formatFirmwareVersionDisplay(version: unknown): string {
|
export function formatFirmwareVersionDisplay(version: unknown): string {
|
||||||
if (version === null || version === undefined || String(version).trim() === "") {
|
if (version === null || version === undefined) return "—";
|
||||||
return "—";
|
const raw = String(version).trim();
|
||||||
}
|
if (!raw) return "—";
|
||||||
const n = parseFirmwareVersion(version);
|
if (/^1\.0\.0\./i.test(raw)) return raw;
|
||||||
const major = Math.floor(n / 1000);
|
return `1.0.0.${raw}`;
|
||||||
const minor = Math.floor((n % 1000) / 100);
|
|
||||||
const patch = 0;
|
|
||||||
const build = n % 10;
|
|
||||||
return `${major}.${minor}.${patch}.${build}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pre-out volume passthrough mode selected (0dB or -12dB). */
|
/** Max `bootSound` index available for the current firmware. */
|
||||||
export function isVolumePassthroughActive(dacVolumeDirect: number | undefined): boolean {
|
export function getBootSoundMaxIndex(firmwareVersion: number): number {
|
||||||
return dacVolumeDirect === 1 || dacVolumeDirect === 2;
|
return firmwareVersion >= 26 ? 10 : 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lock home volume controls when passthrough is on, except output is headphone. */
|
/** Ambient LED block on System page (firmware build 28+). */
|
||||||
export function isHomeVolumeLockedByPassthrough(
|
export const AMBIENT_LED_MIN_FIRMWARE_VERSION = 28;
|
||||||
dacVolumeDirect: number | undefined,
|
|
||||||
output: number | undefined,
|
export function supportsAmbientLed(firmwareVersion: number): boolean {
|
||||||
): boolean {
|
return firmwareVersion >= AMBIENT_LED_MIN_FIRMWARE_VERSION;
|
||||||
if (!isVolumePassthroughActive(dacVolumeDirect)) return false;
|
|
||||||
return (output ?? -1) !== OUTPUT_HEADSET_INDEX;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getBootSoundMaxIndex } from "@/config/firmwareFeatures";
|
export function clampLedChannel(value: unknown): number {
|
||||||
|
const n = typeof value === "number" ? value : Number(value);
|
||||||
|
if (!Number.isFinite(n)) return 0;
|
||||||
|
return Math.max(0, Math.min(255, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
/** Normalize `bootSound` from syncData (0 = default … 10 = -50 dB). */
|
/** Normalize `bootSound` from syncData (0 = default … 10 = -50 dB). */
|
||||||
export function normalizeBootSound(value: unknown): number {
|
export function normalizeBootSound(value: unknown): number {
|
||||||
@@ -476,8 +425,8 @@ export function readBootSoundFromState(state: DeviceState | null | undefined): n
|
|||||||
// Mock data for demo/offline mode
|
// Mock data for demo/offline mode
|
||||||
// ============================================================
|
// ============================================================
|
||||||
export const MOCK_DEVICE_STATE: DeviceState = {
|
export const MOCK_DEVICE_STATE: DeviceState = {
|
||||||
device: "Luxsin-X9",
|
device: "Luxsin-X8",
|
||||||
version: "1.2.3",
|
version: 28,
|
||||||
mac: "AA:BB:CC:DD:EE:FF",
|
mac: "AA:BB:CC:DD:EE:FF",
|
||||||
language: 0,
|
language: 0,
|
||||||
volume: 75,
|
volume: 75,
|
||||||
@@ -508,6 +457,7 @@ export const MOCK_DEVICE_STATE: DeviceState = {
|
|||||||
dacGain: 0,
|
dacGain: 0,
|
||||||
dacArc: 0,
|
dacArc: 0,
|
||||||
dacImpedance: 0,
|
dacImpedance: 0,
|
||||||
|
dreMode: 0,
|
||||||
dacVolumeDirect: 0,
|
dacVolumeDirect: 0,
|
||||||
analogGain: 0,
|
analogGain: 0,
|
||||||
effect_enable: 0,
|
effect_enable: 0,
|
||||||
@@ -522,31 +472,19 @@ export const MOCK_DEVICE_STATE: DeviceState = {
|
|||||||
subwoofer_value: 80,
|
subwoofer_value: 80,
|
||||||
subwoofer_rate: 0,
|
subwoofer_rate: 0,
|
||||||
subwoofer_gain: 0,
|
subwoofer_gain: 0,
|
||||||
subwoofer_mix_type: 0,
|
|
||||||
subwoofer_delay: 582,
|
|
||||||
subwoofer_delay_main: 571,
|
|
||||||
subwoofer_delay_r: 582,
|
|
||||||
subwoofer_delay_main_r: 571,
|
|
||||||
subwoofer_lpf_enable: 0,
|
|
||||||
subwoofer_hpf_enable: 0,
|
|
||||||
loudness_enable: 0,
|
loudness_enable: 0,
|
||||||
loudness_bass_gain: 3,
|
loudness_bass_gain: 3,
|
||||||
loudness_treble_gain: 2,
|
loudness_treble_gain: 2,
|
||||||
loudness_threshold_gain: 60,
|
loudness_threshold_gain: 60,
|
||||||
hearing_enable: 0,
|
|
||||||
hearing_select: 0,
|
|
||||||
hearing_data: [
|
|
||||||
{
|
|
||||||
n: "testing",
|
|
||||||
l: [1.7, 0.8, 0.5, 0.5, 0.5, 0.5, 0.4],
|
|
||||||
r: [0.4, 0.4, 0.3, 0.3, 0.2, 0.4, 0.3],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
bt_status: 1,
|
bt_status: 1,
|
||||||
bt_srcname: "iPhone 15 Pro",
|
bt_srcname: "iPhone 15 Pro",
|
||||||
bt_title: "Bohemian Rhapsody",
|
bt_title: "Bohemian Rhapsody",
|
||||||
bt_artist: "Queen",
|
bt_artist: "Queen",
|
||||||
msgCount: 42,
|
msgCount: 42,
|
||||||
|
led_enable: 1,
|
||||||
|
led_red: 128,
|
||||||
|
led_green: 64,
|
||||||
|
led_blue: 200,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MOCK_PEQ_STATE: PeqState = {
|
export const MOCK_PEQ_STATE: PeqState = {
|
||||||
|
|||||||
+124
-30
@@ -8,6 +8,16 @@
|
|||||||
============================================================ */
|
============================================================ */
|
||||||
import BottomNav from "@/components/BottomNav";
|
import BottomNav from "@/components/BottomNav";
|
||||||
import ConnectionPlaceholder from "@/components/ConnectionPlaceholder";
|
import ConnectionPlaceholder from "@/components/ConnectionPlaceholder";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
import { useDevice } from "@/contexts/DeviceContext";
|
import { useDevice } from "@/contexts/DeviceContext";
|
||||||
import {
|
import {
|
||||||
AI_SPLIT_RESTORE_PATH_KEY,
|
AI_SPLIT_RESTORE_PATH_KEY,
|
||||||
@@ -30,7 +40,6 @@ import {
|
|||||||
} from "@/lib/aiApi";
|
} from "@/lib/aiApi";
|
||||||
|
|
||||||
import { buildPeqSvgCurveData, getFilterType, PeqBandForResponse } from "@/lib/peqAudio";
|
import { buildPeqSvgCurveData, getFilterType, PeqBandForResponse } from "@/lib/peqAudio";
|
||||||
import { buildPeqPresetBody } from "@/lib/luxsinApi";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
@@ -206,12 +215,18 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
const [routePath, setLocation] = useLocation();
|
const [routePath, setLocation] = useLocation();
|
||||||
const isLgUp = useIsLgUp();
|
const isLgUp = useIsLgUp();
|
||||||
const { setOpen: setAiPanelOpen } = useAIDrawer();
|
const { setOpen: setAiPanelOpen } = useAIDrawer();
|
||||||
const { isConnected, deviceState, api } = useDevice();
|
const { isConnected, deviceState, api, updateSetting } = useDevice();
|
||||||
const mac = deviceState?.mac ?? "";
|
const mac = deviceState?.mac ?? "";
|
||||||
const language = resolveDeviceLanguage(deviceState?.language);
|
const language = resolveDeviceLanguage(deviceState?.language);
|
||||||
const deviceName = deviceState?.device ?? "Luxsin X9";
|
const deviceName = deviceState?.device ?? "Luxsin X9";
|
||||||
|
|
||||||
const aiText = useMemo(() => resolveAILocale(language), [language]);
|
const aiText = useMemo(() => resolveAILocale(language), [language]);
|
||||||
|
const promptText = useMemo(() => resolveLocalePack(language).prompt, [language]);
|
||||||
|
const eqUi = useMemo(() => {
|
||||||
|
const pack = resolveLocalePack(language);
|
||||||
|
const peq = pack.peq as typeof localeZh.peq;
|
||||||
|
return peq.eqUi ?? (localeEn.peq as typeof localeEn.peq).eqUi;
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
const restoreDesktopSplit = useCallback(() => {
|
const restoreDesktopSplit = useCallback(() => {
|
||||||
const path = readAndClearAiSplitRestorePath();
|
const path = readAndClearAiSplitRestorePath();
|
||||||
@@ -224,6 +239,8 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||||
|
const [pendingApplyMsg, setPendingApplyMsg] = useState<OptimizeUIMessage | null>(null);
|
||||||
const [chats, setChats] = useState<ChatRead[]>([]);
|
const [chats, setChats] = useState<ChatRead[]>([]);
|
||||||
// 初次加载历史会话期间,避免先闪一下欢迎页再切到消息
|
// 初次加载历史会话期间,避免先闪一下欢迎页再切到消息
|
||||||
const [initializing, setInitializing] = useState(true);
|
const [initializing, setInitializing] = useState(true);
|
||||||
@@ -383,11 +400,8 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
}
|
}
|
||||||
}, [aiText, chatId, newChat]);
|
}, [aiText, chatId, newChat]);
|
||||||
|
|
||||||
// ── 历史面板里删除某条会话 ──
|
// ── 历史面板里删除某条会话(确认框用 AlertDialog,WebView 不支持 window.confirm) ──
|
||||||
const deleteChatById = useCallback(
|
const confirmDeleteChat = useCallback(async (id: string) => {
|
||||||
async (id: string) => {
|
|
||||||
const ok = window.confirm(aiText.history.deleteConfirm);
|
|
||||||
if (!ok) return;
|
|
||||||
try {
|
try {
|
||||||
await clearChat(id);
|
await clearChat(id);
|
||||||
setChats((prev) => prev.filter((c) => c.id !== id));
|
setChats((prev) => prev.filter((c) => c.id !== id));
|
||||||
@@ -401,9 +415,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
formatText(aiText.history.deleteFailed, { message: e?.message ?? e }),
|
formatText(aiText.history.deleteFailed, { message: e?.message ?? e }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
}, [aiText, chatId]);
|
||||||
[aiText, chatId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── 发送提问 ──
|
// ── 发送提问 ──
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
@@ -621,8 +633,6 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
autoPre: selected?.autoPre,
|
autoPre: selected?.autoPre,
|
||||||
brand: selected?.brand,
|
brand: selected?.brand,
|
||||||
model: selected?.model,
|
model: selected?.model,
|
||||||
target: selected?.target,
|
|
||||||
form: selected?.form,
|
|
||||||
filters: (selectedFilters ?? peqState.filters ?? []) as OptimizePeqPayload["filters"],
|
filters: (selectedFilters ?? peqState.filters ?? []) as OptimizePeqPayload["filters"],
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -634,7 +644,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
if (isConnected) void refreshCurrentDevicePeq();
|
if (isConnected) void refreshCurrentDevicePeq();
|
||||||
}, [isConnected, refreshCurrentDevicePeq]);
|
}, [isConnected, refreshCurrentDevicePeq]);
|
||||||
|
|
||||||
const handleApplyToggle = useCallback(
|
const executeApplyToggle = useCallback(
|
||||||
async (msg: OptimizeUIMessage) => {
|
async (msg: OptimizeUIMessage) => {
|
||||||
if (!api) {
|
if (!api) {
|
||||||
toast.error(aiText.deviceDisconnected);
|
toast.error(aiText.deviceDisconnected);
|
||||||
@@ -642,28 +652,21 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
}
|
}
|
||||||
const nextApplied = !msg.applied;
|
const nextApplied = !msg.applied;
|
||||||
const targetPeq = nextApplied ? msg.afterPeq : msg.beforePeq;
|
const targetPeq = nextApplied ? msg.afterPeq : msg.beforePeq;
|
||||||
const metaSource = currentDevicePeq ?? targetPeq;
|
|
||||||
setApplyingId(msg.id);
|
setApplyingId(msg.id);
|
||||||
try {
|
try {
|
||||||
await api.upgradePeqChange({
|
await api.upgradePeqChange({
|
||||||
peqChange: buildPeqPresetBody(
|
peqChange: {
|
||||||
{
|
name: targetPeq.name ?? msg.name,
|
||||||
name: targetPeq.name ?? msg.name ?? metaSource?.name ?? "",
|
filters: (targetPeq.filters ?? []).map((f) => ({
|
||||||
brand: targetPeq.brand ?? metaSource?.brand,
|
|
||||||
model: targetPeq.model ?? metaSource?.model,
|
|
||||||
target: targetPeq.target ?? metaSource?.target,
|
|
||||||
form: targetPeq.form ?? metaSource?.form,
|
|
||||||
autoPre: targetPeq.autoPre ?? metaSource?.autoPre,
|
|
||||||
preamp: targetPeq.preamp ?? metaSource?.preamp,
|
|
||||||
canDel: targetPeq.canDel ?? metaSource?.canDel,
|
|
||||||
},
|
|
||||||
(targetPeq.filters ?? []).map((f) => ({
|
|
||||||
type: getFilterType(f.type),
|
type: getFilterType(f.type),
|
||||||
fc: (f.fc ?? f.frequency ?? 1000) as number,
|
fc: (f.fc ?? f.frequency ?? 1000) as number,
|
||||||
gain: f.gain,
|
gain: f.gain,
|
||||||
q: f.q,
|
q: f.q,
|
||||||
})),
|
})),
|
||||||
),
|
autoPre: targetPeq.autoPre,
|
||||||
|
preamp: targetPeq.preamp,
|
||||||
|
canDel: targetPeq.canDel,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await updateMessageApplied(msg.id, nextApplied);
|
await updateMessageApplied(msg.id, nextApplied);
|
||||||
await api.refreshState();
|
await api.refreshState();
|
||||||
@@ -687,7 +690,35 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
setApplyingId(null);
|
setApplyingId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[aiText, api, currentDevicePeq, refreshCurrentDevicePeq],
|
[aiText, api, refreshCurrentDevicePeq],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleApplyToggle = useCallback(
|
||||||
|
(msg: OptimizeUIMessage) => {
|
||||||
|
if (!api) {
|
||||||
|
toast.error(aiText.deviceDisconnected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((deviceState?.peqEnable ?? 0) !== 1) {
|
||||||
|
setPendingApplyMsg(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void executeApplyToggle(msg);
|
||||||
|
},
|
||||||
|
[aiText, api, deviceState?.peqEnable, executeApplyToggle],
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmEnableAndApply = useCallback(
|
||||||
|
async (msg: OptimizeUIMessage) => {
|
||||||
|
try {
|
||||||
|
const bypassOn = (deviceState?.dsp_enable ?? 0) === 0;
|
||||||
|
await updateSetting(bypassOn ? { peqEnable: 1, dsp_enable: 1 } : { peqEnable: 1 });
|
||||||
|
await executeApplyToggle(msg);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(formatText(aiText.optimize.applyFailed, { message: e?.message ?? e }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[aiText, deviceState?.dsp_enable, executeApplyToggle, updateSetting],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openCompare = useCallback(
|
const openCompare = useCallback(
|
||||||
@@ -907,10 +938,72 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
onClose={() => setHistoryOpen(false)}
|
onClose={() => setHistoryOpen(false)}
|
||||||
onSelect={(id) => loadChat(id)}
|
onSelect={(id) => loadChat(id)}
|
||||||
onNew={newChat}
|
onNew={newChat}
|
||||||
onDelete={deleteChatById}
|
onDelete={setPendingDeleteId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={pendingDeleteId !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setPendingDeleteId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent className="top-[42%] z-[70] border-white/10 bg-zinc-900 text-white sm:max-w-md">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-white">
|
||||||
|
{aiText.history.delete}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-white/60">
|
||||||
|
{aiText.history.deleteConfirm}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="border-white/20 bg-transparent text-white hover:bg-white/10">
|
||||||
|
{promptText.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-red-500 text-white hover:bg-red-500/90 focus-visible:ring-red-500"
|
||||||
|
onClick={() => {
|
||||||
|
if (pendingDeleteId) void confirmDeleteChat(pendingDeleteId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{promptText.delete}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={pendingApplyMsg !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setPendingApplyMsg(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent className="top-[42%] z-[70] border-white/10 bg-zinc-900 text-white sm:max-w-md">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-white">
|
||||||
|
{eqUi.enableConfirmTitle}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-white/60">
|
||||||
|
{eqUi.enableConfirmDesc}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="border-white/20 bg-transparent text-white hover:bg-white/10">
|
||||||
|
{eqUi.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-[#00FFF6] text-black hover:bg-[#00FFF6]/90 focus-visible:ring-[#00FFF6]"
|
||||||
|
onClick={() => {
|
||||||
|
if (pendingApplyMsg) void confirmEnableAndApply(pendingApplyMsg);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{eqUi.enableConfirmOk}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
{/* ── 对比对话框 ── */}
|
{/* ── 对比对话框 ── */}
|
||||||
{compareMsg && (
|
{compareMsg && (
|
||||||
<CompareDialog
|
<CompareDialog
|
||||||
@@ -1157,11 +1250,12 @@ function HistoryPanel({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete(c.id);
|
onDelete(c.id);
|
||||||
}}
|
}}
|
||||||
className="ml-2 w-8 h-8 rounded-full flex items-center justify-center text-white/40 hover:text-red-400 active:text-red-500 transition-colors"
|
className="ml-2 w-8 h-8 shrink-0 rounded-full flex items-center justify-center text-white/40 hover:text-red-400 active:text-red-500 transition-colors touch-manipulation"
|
||||||
style={{ background: "rgba(44,44,46,0.6)" }}
|
style={{ background: "rgba(44,44,46,0.6)" }}
|
||||||
aria-label={aiText.history.delete}
|
aria-label={aiText.history.delete}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user