2026-03-31 18:52:37 +08:00
|
|
|
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
|
|
|
|
import {
|
|
|
|
|
DeviceState,
|
|
|
|
|
LuxsinAPI,
|
|
|
|
|
MOCK_DEVICE_STATE,
|
|
|
|
|
MOCK_PEQ_STATE,
|
2026-05-18 13:42:29 +08:00
|
|
|
PeqApplyPayload,
|
2026-04-17 17:57:39 +08:00
|
|
|
PeqChangePayload,
|
2026-03-31 18:52:37 +08:00
|
|
|
PeqFilter,
|
|
|
|
|
PeqState,
|
|
|
|
|
} from "@/lib/luxsinApi";
|
2026-05-22 15:54:11 +08:00
|
|
|
import { getDeviceIpFromUrl, resolveInitialDeviceIp } from "@/lib/deviceIp";
|
2026-03-31 18:52:37 +08:00
|
|
|
|
|
|
|
|
interface DeviceContextType {
|
|
|
|
|
ip: string;
|
|
|
|
|
setIp: (ip: string) => void;
|
2026-05-22 17:24:50 +08:00
|
|
|
ipMissing: boolean;
|
2026-03-31 18:52:37 +08:00
|
|
|
isConnected: boolean;
|
|
|
|
|
isConnecting: boolean;
|
|
|
|
|
isDemoMode: boolean;
|
|
|
|
|
setDemoMode: (demo: boolean) => void;
|
|
|
|
|
deviceState: DeviceState | null;
|
|
|
|
|
peqState: PeqState | null;
|
|
|
|
|
lastUpdated: Date | null;
|
|
|
|
|
error: string | null;
|
2026-04-21 15:29:29 +08:00
|
|
|
connect: (forceDemo?: boolean, connectIp?: string) => Promise<void>;
|
2026-03-31 18:52:37 +08:00
|
|
|
disconnect: () => void;
|
|
|
|
|
refresh: () => Promise<void>;
|
|
|
|
|
api: LuxsinAPI | null;
|
|
|
|
|
updateSetting: (params: Record<string, string | number>) => Promise<void>;
|
|
|
|
|
updatePeq: (filters: PeqFilter[]) => Promise<void>;
|
2026-04-17 17:57:39 +08:00
|
|
|
upgradePeqChange: (payload: PeqChangePayload) => Promise<void>;
|
2026-05-18 13:42:29 +08:00
|
|
|
upgradePeqApply: (payload: PeqApplyPayload) => Promise<void>;
|
2026-05-28 16:26:38 +08:00
|
|
|
/** Align global peqState with device syncPeq (e.g. after EQ catalog add/delete). */
|
|
|
|
|
syncPeqCatalog: (state: PeqState) => void;
|
2026-03-31 18:52:37 +08:00
|
|
|
// Optimistic state updaters
|
|
|
|
|
setVolume: (v: number) => void;
|
|
|
|
|
setInput: (v: number) => void;
|
|
|
|
|
setOutput: (v: number) => void;
|
|
|
|
|
setBalance: (v: number) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DeviceContext = createContext<DeviceContextType | null>(null);
|
|
|
|
|
|
|
|
|
|
export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
2026-05-22 15:54:11 +08:00
|
|
|
const [ip, setIp] = useState(resolveInitialDeviceIp);
|
2026-05-22 17:24:50 +08:00
|
|
|
const [ipMissing, setIpMissing] = useState(() => !resolveInitialDeviceIp().trim());
|
2026-03-31 18:52:37 +08:00
|
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
|
|
|
const [isConnecting, setIsConnecting] = useState(false);
|
|
|
|
|
const [isDemoMode, setDemoMode] = useState(false);
|
|
|
|
|
const isDemoModeRef = useRef(false);
|
|
|
|
|
const [deviceState, setDeviceState] = useState<DeviceState | null>(null);
|
|
|
|
|
const [peqState, setPeqState] = useState<PeqState | null>(null);
|
|
|
|
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [api, setApi] = useState<LuxsinAPI | null>(null);
|
|
|
|
|
const lastMsgCountRef = useRef<number>(-1);
|
|
|
|
|
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
2026-05-22 15:54:11 +08:00
|
|
|
const autoConnectStartedRef = useRef(false);
|
2026-03-31 18:52:37 +08:00
|
|
|
|
2026-05-22 17:24:50 +08:00
|
|
|
const setDeviceIp = useCallback((newIp: string) => {
|
|
|
|
|
setIp(newIp.trim());
|
2026-03-31 18:52:37 +08:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const fetchState = useCallback(async (apiInstance: LuxsinAPI) => {
|
|
|
|
|
const state = await apiInstance.getDeviceState();
|
|
|
|
|
setDeviceState(state);
|
|
|
|
|
setLastUpdated(new Date());
|
|
|
|
|
lastMsgCountRef.current = state.msgCount;
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-26 12:22:45 +08:00
|
|
|
const fetchPeqState = useCallback(async (apiInstance: LuxsinAPI) => {
|
|
|
|
|
const peq = await apiInstance.getPeqState();
|
|
|
|
|
setPeqState(peq);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-28 16:26:38 +08:00
|
|
|
const syncPeqCatalog = useCallback((state: PeqState) => {
|
|
|
|
|
setPeqState(state);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-21 15:29:29 +08:00
|
|
|
const connect = useCallback(async (forceDemo?: boolean, connectIp?: string) => {
|
2026-03-31 18:52:37 +08:00
|
|
|
const useDemo = forceDemo !== undefined ? forceDemo : isDemoMode;
|
2026-04-21 15:29:29 +08:00
|
|
|
const targetIp = (connectIp ?? ip).trim();
|
2026-05-22 17:24:50 +08:00
|
|
|
if (!useDemo && !targetIp) {
|
|
|
|
|
setIpMissing(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
setIsConnecting(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setIpMissing(false);
|
2026-03-31 18:52:37 +08:00
|
|
|
// Demo mode — use mock data immediately, no network call
|
|
|
|
|
if (useDemo) {
|
|
|
|
|
setIsConnecting(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
await new Promise(r => setTimeout(r, 400)); // brief loading feel
|
|
|
|
|
setDeviceState({ ...MOCK_DEVICE_STATE });
|
|
|
|
|
setPeqState({ ...MOCK_PEQ_STATE });
|
|
|
|
|
setIsConnected(true);
|
|
|
|
|
setLastUpdated(new Date());
|
|
|
|
|
setIsConnecting(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsConnecting(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
2026-04-21 15:29:29 +08:00
|
|
|
const newApi = new LuxsinAPI(targetIp);
|
2026-03-31 18:52:37 +08:00
|
|
|
// Fetch device info from http://{ip}/dev/info.cgi?action=syncData
|
|
|
|
|
// The response is encoded with custom base64, needs decoding
|
|
|
|
|
const state = await newApi.getDeviceState();
|
|
|
|
|
setDeviceState(state);
|
|
|
|
|
setLastUpdated(new Date());
|
|
|
|
|
const peq = await newApi.getPeqState().catch(() => MOCK_PEQ_STATE);
|
|
|
|
|
setPeqState(peq);
|
|
|
|
|
setApi(newApi);
|
|
|
|
|
setIsConnected(true);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setError(`Connection failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
|
|
|
setIsConnected(false);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsConnecting(false);
|
|
|
|
|
}
|
|
|
|
|
}, [ip, isDemoMode]);
|
|
|
|
|
|
|
|
|
|
const disconnect = useCallback(() => {
|
|
|
|
|
if (pollIntervalRef.current) {
|
|
|
|
|
clearInterval(pollIntervalRef.current);
|
|
|
|
|
pollIntervalRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
setIsConnected(false);
|
|
|
|
|
setApi(null);
|
|
|
|
|
setDeviceState(null);
|
|
|
|
|
setPeqState(null);
|
|
|
|
|
lastMsgCountRef.current = -1;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const refresh = useCallback(async () => {
|
|
|
|
|
if (isDemoMode) {
|
2026-05-18 13:42:29 +08:00
|
|
|
setDeviceState({ ...MOCK_DEVICE_STATE });
|
2026-03-31 18:52:37 +08:00
|
|
|
setLastUpdated(new Date());
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// If api is null but connected, recreate it with current IP
|
|
|
|
|
let apiInstance = api;
|
|
|
|
|
if (!apiInstance && isConnected) {
|
|
|
|
|
apiInstance = new LuxsinAPI(ip);
|
|
|
|
|
setApi(apiInstance);
|
|
|
|
|
}
|
|
|
|
|
if (!apiInstance) return;
|
|
|
|
|
try {
|
|
|
|
|
await fetchState(apiInstance);
|
2026-05-26 12:22:45 +08:00
|
|
|
await fetchPeqState(apiInstance).catch(() => undefined);
|
2026-03-31 18:52:37 +08:00
|
|
|
} catch (e) {
|
|
|
|
|
setError(`Refresh failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
|
|
|
}
|
2026-05-26 12:22:45 +08:00
|
|
|
}, [api, isConnected, ip, isDemoMode, fetchState, fetchPeqState]);
|
2026-03-31 18:52:37 +08:00
|
|
|
|
|
|
|
|
const updateSetting = useCallback(async (params: Record<string, string | number>) => {
|
|
|
|
|
if (isDemoMode) {
|
|
|
|
|
// Optimistic update for demo mode
|
|
|
|
|
setDeviceState(prev => prev ? { ...prev, ...params } : prev);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!api) return;
|
|
|
|
|
await api.setSetting(params);
|
|
|
|
|
// Optimistic update
|
|
|
|
|
setDeviceState(prev => prev ? { ...prev, ...params } : prev);
|
|
|
|
|
}, [api, isDemoMode]);
|
|
|
|
|
|
|
|
|
|
const updatePeq = useCallback(async (filters: PeqFilter[]) => {
|
|
|
|
|
if (isDemoMode) {
|
|
|
|
|
setPeqState({ filters });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!api) return;
|
|
|
|
|
await api.setPeqFilters(filters);
|
|
|
|
|
setPeqState({ filters });
|
|
|
|
|
}, [api, isDemoMode]);
|
|
|
|
|
|
2026-05-18 13:42:29 +08:00
|
|
|
const applyPeqFiltersToState = useCallback((filters: PeqFilter[]) => {
|
|
|
|
|
setPeqState((prev) => (prev ? { ...prev, filters } : prev));
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-17 17:57:39 +08:00
|
|
|
const upgradePeqChange = useCallback(async (payload: PeqChangePayload) => {
|
|
|
|
|
if (isDemoMode) {
|
2026-05-18 13:42:29 +08:00
|
|
|
applyPeqFiltersToState(payload.peqChange.filters);
|
2026-04-17 17:57:39 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!api) return;
|
|
|
|
|
await api.upgradePeqChange(payload);
|
2026-05-18 13:42:29 +08:00
|
|
|
applyPeqFiltersToState(payload.peqChange.filters);
|
|
|
|
|
}, [api, isDemoMode, applyPeqFiltersToState]);
|
|
|
|
|
|
|
|
|
|
const upgradePeqApply = useCallback(async (payload: PeqApplyPayload) => {
|
|
|
|
|
if (isDemoMode) {
|
|
|
|
|
applyPeqFiltersToState(payload.peqApply.filters);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!api) return;
|
|
|
|
|
await api.upgradePeqApply(payload);
|
|
|
|
|
applyPeqFiltersToState(payload.peqApply.filters);
|
|
|
|
|
}, [api, isDemoMode, applyPeqFiltersToState]);
|
2026-04-17 17:57:39 +08:00
|
|
|
|
2026-03-31 18:52:37 +08:00
|
|
|
// Optimistic setters
|
|
|
|
|
const setVolume = useCallback((v: number) => {
|
|
|
|
|
setDeviceState(prev => prev ? { ...prev, volume: v } : prev);
|
|
|
|
|
if (!isDemoMode && api) {
|
|
|
|
|
api.setVolume(v).catch(console.error);
|
|
|
|
|
}
|
|
|
|
|
}, [api, isDemoMode]);
|
|
|
|
|
|
|
|
|
|
const setInput = useCallback((v: number) => {
|
|
|
|
|
setDeviceState(prev => prev ? { ...prev, input: v } : prev);
|
|
|
|
|
if (!isDemoMode && api) api.setInput(v).catch(console.error);
|
|
|
|
|
}, [api, isDemoMode]);
|
|
|
|
|
|
|
|
|
|
const setOutput = useCallback((v: number) => {
|
|
|
|
|
setDeviceState(prev => prev ? { ...prev, output: v } : prev);
|
|
|
|
|
if (!isDemoMode && api) api.setOutput(v).catch(console.error);
|
|
|
|
|
}, [api, isDemoMode]);
|
|
|
|
|
|
|
|
|
|
const setBalance = useCallback((v: number) => {
|
|
|
|
|
setDeviceState(prev => prev ? { ...prev, balance: v } : prev);
|
|
|
|
|
if (!isDemoMode && api) api.setBalance(v).catch(console.error);
|
|
|
|
|
}, [api, isDemoMode]);
|
|
|
|
|
|
2026-05-22 15:54:11 +08:00
|
|
|
// 启动时根据 ?ip= 或已存 IP 自动连接(默认进入首页,无需连接页)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (autoConnectStartedRef.current) return;
|
|
|
|
|
autoConnectStartedRef.current = true;
|
|
|
|
|
|
2026-05-22 17:24:50 +08:00
|
|
|
const targetIp = getDeviceIpFromUrl()?.trim() ?? "";
|
|
|
|
|
if (!targetIp) {
|
|
|
|
|
setIpMissing(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setIp(targetIp);
|
|
|
|
|
setIpMissing(false);
|
2026-05-22 15:54:11 +08:00
|
|
|
void connect(false, targetIp);
|
2026-05-22 17:24:50 +08:00
|
|
|
}, [connect]);
|
2026-05-22 15:54:11 +08:00
|
|
|
|
2026-03-31 18:52:37 +08:00
|
|
|
// Polling for changes
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isConnected || isDemoMode || !api) return;
|
|
|
|
|
|
|
|
|
|
const poll = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const count = await api.getMsgCount();
|
|
|
|
|
if (count !== lastMsgCountRef.current) {
|
2026-05-26 12:22:45 +08:00
|
|
|
await Promise.all([
|
|
|
|
|
fetchState(api),
|
|
|
|
|
fetchPeqState(api).catch(() => undefined),
|
|
|
|
|
]);
|
2026-03-31 18:52:37 +08:00
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Silently fail polling
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
pollIntervalRef.current = setInterval(poll, 3000);
|
|
|
|
|
return () => {
|
|
|
|
|
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
|
|
|
|
|
};
|
2026-05-26 12:22:45 +08:00
|
|
|
}, [isConnected, isDemoMode, api, fetchState, fetchPeqState]);
|
2026-03-31 18:52:37 +08:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DeviceContext.Provider value={{
|
2026-05-22 17:24:50 +08:00
|
|
|
ip, setIp: setDeviceIp, ipMissing,
|
2026-03-31 18:52:37 +08:00
|
|
|
isConnected, isConnecting, isDemoMode, setDemoMode,
|
|
|
|
|
deviceState, peqState, lastUpdated, error,
|
|
|
|
|
connect, disconnect, refresh, api,
|
2026-05-28 16:26:38 +08:00
|
|
|
updateSetting, updatePeq, upgradePeqChange, upgradePeqApply, syncPeqCatalog,
|
2026-03-31 18:52:37 +08:00
|
|
|
setVolume, setInput, setOutput, setBalance,
|
|
|
|
|
}}>
|
|
|
|
|
{children}
|
|
|
|
|
</DeviceContext.Provider>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useDevice() {
|
|
|
|
|
const ctx = useContext(DeviceContext);
|
|
|
|
|
if (!ctx) throw new Error("useDevice must be used within DeviceProvider");
|
|
|
|
|
return ctx;
|
|
|
|
|
}
|