import merge from 'lodash/merge'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import { createWithEqualityFn } from 'zustand/traditional'; import { toast } from '/@/shared/components/toast/toast'; import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/shared/types/remote-types'; export interface SettingsSlice extends SettingsState { actions: { reconnect: () => void; send: (data: ClientEvent) => void; toggleIsDark: () => void; toggleShowImage: () => void; }; } interface SettingsState { connected: boolean; info: Omit; isDark: boolean; showImage: boolean; socket?: StatefulWebSocket; } interface StatefulWebSocket extends WebSocket { natural: boolean; } const initialState: SettingsState = { connected: false, info: {}, isDark: window.matchMedia('(prefers-color-scheme: dark)').matches, showImage: true, }; export const useRemoteStore = createWithEqualityFn()( persist( devtools( immer((set, get) => ({ actions: { reconnect: async () => { const existing = get().socket; if (existing) { if ( existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING ) { existing.natural = true; existing.close(4001); } } let authHeader: string | undefined; try { const credentials = await fetch('/credentials'); authHeader = await credentials.text(); } catch (error) { console.error('Failed to get credentials', error); } set((state) => { const socket = new WebSocket( location.href.replace('http', 'ws'), ) as StatefulWebSocket; socket.natural = false; socket.addEventListener('message', (message) => { const { data, event } = JSON.parse(message.data) as ServerEvent; switch (event) { case 'error': { toast.error({ message: data, title: 'Socket error' }); break; } case 'favorite': { set((state) => { if (state.info.song?.id === data.id) { state.info.song.userFavorite = data.favorite; } }); break; } case 'playback': { set((state) => { state.info.status = data; }); break; } case 'position': { set((state) => { state.info.position = data; }); break; } case 'proxy': { set((state) => { if (state.info.song) { state.info.song.imageUrl = `data:image/jpeg;base64,${data}`; } }); break; } case 'rating': { set((state) => { if (state.info.song?.id === data.id) { state.info.song.userRating = data.rating; } }); break; } case 'repeat': { set((state) => { state.info.repeat = data; }); break; } case 'shuffle': { set((state) => { state.info.shuffle = data; }); break; } case 'song': { set((state) => { state.info.song = data; }); break; } case 'state': { set((state) => { state.info = data; }); break; } case 'volume': { set((state) => { state.info.volume = data; }); } } }); socket.addEventListener('open', () => { if (authHeader) { socket.send( JSON.stringify({ event: 'authenticate', header: authHeader, }), ); } set({ connected: true }); }); socket.addEventListener('close', (reason) => { if (reason.code === 4002 || reason.code === 4003) { location.reload(); } else if (reason.code === 4000) { toast.warn({ message: 'Feishin remote server is down', title: 'Connection closed', }); } else if (reason.code !== 4001 && !socket.natural) { toast.error({ message: 'Socket closed for unexpected reason', title: 'Connection closed', }); } if (!socket.natural) { set({ connected: false, info: {} }); } }); state.socket = socket; }); }, send: (data: ClientEvent) => { get().socket?.send(JSON.stringify(data)); }, toggleIsDark: () => { set((state) => { state.isDark = !state.isDark; }); }, toggleShowImage: () => { set((state) => { state.showImage = !state.showImage; }); }, }, ...initialState, })), { name: 'store_settings' }, ), { merge: (persistedState, currentState) => merge(currentState, persistedState), name: 'store_settings', version: 7, }, ), ); export const useConnected = () => useRemoteStore((state) => state.connected); export const useInfo = () => useRemoteStore((state) => state.info); export const useIsDark = () => useRemoteStore((state) => state.isDark); export const useReconnect = () => useRemoteStore((state) => state.actions.reconnect); export const useShowImage = () => useRemoteStore((state) => state.showImage); export const useSend = () => useRemoteStore((state) => state.actions.send); export const useToggleDark = () => useRemoteStore((state) => state.actions.toggleIsDark); export const useToggleShowImage = () => useRemoteStore((state) => state.actions.toggleShowImage);