Files
controller-v2/client/src/contexts/DeviceContext.tsx
T

258 lines
8.4 KiB
TypeScript
Raw Normal View History

import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import {
DeviceState,
LuxsinAPI,
MOCK_DEVICE_STATE,
MOCK_PEQ_STATE,
PeqApplyPayload,
PeqChangePayload,
PeqFilter,
PeqState,
} from "@/lib/luxsinApi";
import { getDeviceIpFromUrl, resolveInitialDeviceIp } from "@/lib/deviceIp";
interface DeviceContextType {
ip: string;
setIp: (ip: string) => void;
isConnected: boolean;
isConnecting: boolean;
isDemoMode: boolean;
setDemoMode: (demo: boolean) => void;
deviceState: DeviceState | null;
peqState: PeqState | null;
lastUpdated: Date | null;
error: string | null;
connect: (forceDemo?: boolean, connectIp?: string) => Promise<void>;
disconnect: () => void;
refresh: () => Promise<void>;
api: LuxsinAPI | null;
updateSetting: (params: Record<string, string | number>) => Promise<void>;
updatePeq: (filters: PeqFilter[]) => Promise<void>;
upgradePeqChange: (payload: PeqChangePayload) => Promise<void>;
upgradePeqApply: (payload: PeqApplyPayload) => Promise<void>;
// 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 }) {
const [ip, setIp] = useState(resolveInitialDeviceIp);
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);
const autoConnectStartedRef = useRef(false);
const saveIp = useCallback((newIp: string) => {
setIp(newIp);
localStorage.setItem("luxsin_ip", newIp);
}, []);
const fetchState = useCallback(async (apiInstance: LuxsinAPI) => {
const state = await apiInstance.getDeviceState();
setDeviceState(state);
setLastUpdated(new Date());
lastMsgCountRef.current = state.msgCount;
}, []);
const connect = useCallback(async (forceDemo?: boolean, connectIp?: string) => {
const useDemo = forceDemo !== undefined ? forceDemo : isDemoMode;
const targetIp = (connectIp ?? ip).trim();
// 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 {
const newApi = new LuxsinAPI(targetIp);
// 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) {
setDeviceState({ ...MOCK_DEVICE_STATE });
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);
} catch (e) {
setError(`Refresh failed: ${e instanceof Error ? e.message : String(e)}`);
}
}, [api, isConnected, ip, isDemoMode, fetchState]);
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]);
const applyPeqFiltersToState = useCallback((filters: PeqFilter[]) => {
setPeqState((prev) => (prev ? { ...prev, filters } : prev));
}, []);
const upgradePeqChange = useCallback(async (payload: PeqChangePayload) => {
if (isDemoMode) {
applyPeqFiltersToState(payload.peqChange.filters);
return;
}
if (!api) return;
await api.upgradePeqChange(payload);
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]);
// 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]);
// 启动时根据 ?ip= 或已存 IP 自动连接(默认进入首页,无需连接页)
useEffect(() => {
if (autoConnectStartedRef.current) return;
autoConnectStartedRef.current = true;
const urlIp = getDeviceIpFromUrl();
const targetIp = urlIp ?? ip;
if (urlIp) saveIp(urlIp);
void connect(false, targetIp);
}, [connect, ip, saveIp]);
// Polling for changes
useEffect(() => {
if (!isConnected || isDemoMode || !api) return;
const poll = async () => {
try {
const count = await api.getMsgCount();
if (count !== lastMsgCountRef.current) {
await fetchState(api);
}
} catch {
// Silently fail polling
}
};
pollIntervalRef.current = setInterval(poll, 3000);
return () => {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
};
}, [isConnected, isDemoMode, api, fetchState]);
return (
<DeviceContext.Provider value={{
ip, setIp: saveIp,
isConnected, isConnecting, isDemoMode, setDemoMode,
deviceState, peqState, lastUpdated, error,
connect, disconnect, refresh, api,
updateSetting, updatePeq, upgradePeqChange, upgradePeqApply,
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;
}