diff --git a/backend/src/MediaPlayer.ts b/backend/src/MediaPlayer.ts index 049b40a..6ddaf3e 100644 --- a/backend/src/MediaPlayer.ts +++ b/backend/src/MediaPlayer.ts @@ -4,11 +4,21 @@ import { WebSocket } from "ws"; import { getLinkPreview } from "link-preview-js"; import { PlaylistItem, LinkMetadata } from './types'; import { FavoritesStore } from "./FavoritesStore"; + interface PendingCommand { resolve: (value: any) => void; reject: (reason: any) => void; } +enum UserEvent { + PlaylistUpdate = "playlist_update", + NowPlayingUpdate = "now_playing_update", + VolumeUpdate = "volume_update", + FavoritesUpdate = "favorites_update", + MetadataUpdate = "metadata_update", + MPDUpdate = "mpd_update", +} + export class MediaPlayer { private playerProcess: ChildProcess; private socket: Socket; @@ -43,7 +53,7 @@ export class MediaPlayer { this.favoritesStore = new FavoritesStore(); this.favoritesStore.onFavoritesChanged = (favorites) => { - this.handleEvent("favorites_update", { favorites }); + this.handleEvent(UserEvent.FavoritesUpdate, { favorites }); }; } @@ -123,31 +133,31 @@ export class MediaPlayer { } public async play() { - return this.modify(() => this.writeCommand("set_property", ["pause", false])); + return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", false])); } public async pause() { - return this.modify(() => this.writeCommand("set_property", ["pause", true])); + return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", true])); } public async skip() { - return this.modify(() => this.writeCommand("playlist-next", [])); + return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", [])); } public async skipTo(index: number) { - return this.modify(() => this.writeCommand("playlist-play-index", [index])); + return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index])); } public async previous() { - return this.modify(() => this.writeCommand("playlist-prev", [])); + return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", [])); } public async deletePlaylistItem(index: number) { - return this.modify(() => this.writeCommand("playlist-remove", [index])); + return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-remove", [index])); } public async setVolume(volume: number) { - return this.modify(() => this.writeCommand("set_property", ["volume", volume])); + return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume])); } public subscribe(ws: WebSocket) { @@ -175,18 +185,18 @@ export class MediaPlayer { } private async loadFile(url: string, mode: string) { - this.modify(() => this.writeCommand("loadfile", [url, mode])); + this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode])); this.fetchMetadataAndNotify(url).catch(error => { console.warn(`Failed to fetch metadata for ${url}:`, error); }); } - private async modify(func: () => Promise): Promise { + private async modify(event: UserEvent, func: () => Promise): Promise { return func() .then((result) => { // Notify all subscribers - this.handleEvent("user_modify", {}); + this.handleEvent(event, {}); return result; }); } @@ -230,7 +240,7 @@ export class MediaPlayer { console.log(this.metadata.get(url)); // Notify clients that metadata has been updated - this.handleEvent("metadata_update", { + this.handleEvent(UserEvent.MetadataUpdate, { url, metadata: this.metadata.get(url) }); @@ -272,7 +282,7 @@ export class MediaPlayer { this.pendingCommands.delete(response.request_id); } } else if (response.event) { - this.handleEvent(response.event, response); + this.handleEvent(UserEvent.MPDUpdate, response); } else { console.log(response); } diff --git a/frontend/src/api/player.tsx b/frontend/src/api/player.tsx index 2010d1f..c055694 100644 --- a/frontend/src/api/player.tsx +++ b/frontend/src/api/player.tsx @@ -46,6 +46,15 @@ export interface SearchResponse { results: SearchResult[]; } +export enum ServerEvent { + PlaylistUpdate = "playlist_update", + NowPlayingUpdate = "now_playing_update", + VolumeUpdate = "volume_update", + FavoritesUpdate = "favorites_update", + MetadataUpdate = "metadata_update", + MPDUpdate = "mpd_update", +} + export const API = { async getPlaylist(): Promise { const response = await fetch('/api/playlist'); diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index a49d2c3..dc10dd0 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -3,9 +3,9 @@ import SongTable from './SongTable'; import NowPlaying from './NowPlaying'; import AddSongPanel from './AddSongPanel'; import { TabView, Tab } from './TabView'; -import { API, getDisplayTitle, PlaylistItem } from '../api/player'; -import { useEventWebSocket } from '../hooks/useEventWebsocket'; +import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player'; import { FaMusic, FaHeart } from 'react-icons/fa'; +import useWebSocket from 'react-use-websocket'; enum Tabs { Playlist = "playlist", @@ -96,14 +96,17 @@ const App: React.FC = () => { }, []); const fetchNowPlaying = useCallback(async () => { + if (volumeSettingIsLocked) { + // We are actively changing the volume, which we do actually want to send events + // continuously to the server, but we don't want to refresh our state while doing that. + return; + } + const nowPlaying = await API.getNowPlaying(); setNowPlayingSong(getDisplayTitle(nowPlaying.playingItem)); setNowPlayingFileName(nowPlaying.playingItem.filename); setIsPlaying(!nowPlaying.isPaused); - - if (!volumeSettingIsLocked) { - setVolume(nowPlaying.volume); - } + setVolume(nowPlaying.volume); }, [volumeSettingIsLocked]); const handleAddURL = async (url: string) => { @@ -148,23 +151,41 @@ const App: React.FC = () => { await API.setVolume(volume); }; - const handleWebSocketEvent = useCallback((event: any) => { + const handleWebSocketEvent = useCallback((message: MessageEvent) => { + const event = JSON.parse(message.data); switch (event.event) { - case 'user_modify': - case 'end-file': - case 'playback-restart': - case 'metadata_update': + case ServerEvent.PlaylistUpdate: + case ServerEvent.NowPlayingUpdate: + case ServerEvent.MetadataUpdate: + case ServerEvent.MPDUpdate: fetchPlaylist(); fetchNowPlaying(); break; - case 'favorites_update': + case ServerEvent.VolumeUpdate: + if (!volumeSettingIsLocked) { + fetchNowPlaying(); + } + + break; + case ServerEvent.FavoritesUpdate: fetchFavorites(); break; } }, [fetchPlaylist, fetchNowPlaying, fetchFavorites]); - // Use the hook - useEventWebSocket(handleWebSocketEvent); + useWebSocket('/api/events', { + onOpen: () => { + console.log('WebSocket connected'); + }, + onClose: () => { + console.log('WebSocket disconnected'); + }, + onError: (error) => { + console.error('WebSocket error:', error); + }, + onMessage: handleWebSocketEvent, + shouldReconnect: () => true, + }); // Handle visibility changes useEffect(() => { diff --git a/frontend/src/hooks/useEventWebsocket.ts b/frontend/src/hooks/useEventWebsocket.ts deleted file mode 100644 index 5c9e226..0000000 --- a/frontend/src/hooks/useEventWebsocket.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { API } from '../api/player'; - -export const useEventWebSocket = (onEvent: (event: any) => void) => { - const [ws, setWs] = useState(null); - const [wsReconnectTimeout, setWsReconnectTimeout] = useState(null); - - const connectWebSocket = useCallback(() => { - // Clear any existing reconnection timeout - if (wsReconnectTimeout) { - window.clearTimeout(wsReconnectTimeout); - setWsReconnectTimeout(null); - } - - // Close existing websocket if it exists - if (ws) { - ws.close(); - } - - const websocket = API.subscribeToEvents(onEvent); - - websocket.addEventListener('close', () => { - console.log('WebSocket closed. Attempting to reconnect...'); - - // Attempt to reconnect after 2 seconds - const timeout = window.setTimeout(() => { - connectWebSocket(); - }, 2000); - - setWsReconnectTimeout(timeout); - }); - - websocket.addEventListener('error', (error) => { - console.error('WebSocket error:', error); - websocket.close(); - }); - - setWs(websocket); - }, [wsReconnectTimeout, onEvent]); - - // Check WebSocket health periodically - useEffect(() => { - const healthCheck = setInterval(() => { - if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) { - console.log('WebSocket unhealthy, reconnecting...'); - connectWebSocket(); - } - }, 5000); - - return () => { - clearInterval(healthCheck); - }; - }, [ws, connectWebSocket]); - - // Initial WebSocket connection - useEffect(() => { - connectWebSocket(); - - return () => { - if (ws) { - ws.close(); - } - if (wsReconnectTimeout) { - window.clearTimeout(wsReconnectTimeout); - } - }; - }, [connectWebSocket]); - - return ws; -}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6cdc2fa..6a6e7cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,10 @@ "workspaces": [ "backend", "frontend" - ] + ], + "dependencies": { + "react-use-websocket": "^4.13.0" + } }, "backend": { "name": "mpvqueue", @@ -4685,6 +4688,12 @@ "node": ">=0.10.0" } }, + "node_modules/react-use-websocket": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.13.0.tgz", + "integrity": "sha512-anMuVoV//g2N76Wxqvqjjo1X48r9Np3y1/gMl7arX84tAPXdy5R7sB5lO5hvCzQRYjqXwV8XMAiEBOUbyrZFrw==", + "license": "MIT" + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", diff --git a/package.json b/package.json index 8047963..a026c14 100644 --- a/package.json +++ b/package.json @@ -8,5 +8,8 @@ "workspaces": [ "backend", "frontend" - ] + ], + "dependencies": { + "react-use-websocket": "^4.13.0" + } }