Smarter/more granular event handling

This commit is contained in:
2025-02-23 16:37:39 -08:00
parent fe05a27b51
commit 687a7fc555
6 changed files with 81 additions and 99 deletions

View File

@@ -4,11 +4,21 @@ import { WebSocket } from "ws";
import { getLinkPreview } from "link-preview-js"; import { getLinkPreview } from "link-preview-js";
import { PlaylistItem, LinkMetadata } from './types'; import { PlaylistItem, LinkMetadata } from './types';
import { FavoritesStore } from "./FavoritesStore"; import { FavoritesStore } from "./FavoritesStore";
interface PendingCommand { interface PendingCommand {
resolve: (value: any) => void; resolve: (value: any) => void;
reject: (reason: 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 { export class MediaPlayer {
private playerProcess: ChildProcess; private playerProcess: ChildProcess;
private socket: Socket; private socket: Socket;
@@ -43,7 +53,7 @@ export class MediaPlayer {
this.favoritesStore = new FavoritesStore(); this.favoritesStore = new FavoritesStore();
this.favoritesStore.onFavoritesChanged = (favorites) => { this.favoritesStore.onFavoritesChanged = (favorites) => {
this.handleEvent("favorites_update", { favorites }); this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
}; };
} }
@@ -123,31 +133,31 @@ export class MediaPlayer {
} }
public async play() { 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() { 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() { public async skip() {
return this.modify(() => this.writeCommand("playlist-next", [])); return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
} }
public async skipTo(index: number) { 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() { public async previous() {
return this.modify(() => this.writeCommand("playlist-prev", [])); return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
} }
public async deletePlaylistItem(index: number) { 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) { 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) { public subscribe(ws: WebSocket) {
@@ -175,18 +185,18 @@ export class MediaPlayer {
} }
private async loadFile(url: string, mode: string) { 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 => { this.fetchMetadataAndNotify(url).catch(error => {
console.warn(`Failed to fetch metadata for ${url}:`, error); console.warn(`Failed to fetch metadata for ${url}:`, error);
}); });
} }
private async modify<T>(func: () => Promise<T>): Promise<T> { private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
return func() return func()
.then((result) => { .then((result) => {
// Notify all subscribers // Notify all subscribers
this.handleEvent("user_modify", {}); this.handleEvent(event, {});
return result; return result;
}); });
} }
@@ -230,7 +240,7 @@ export class MediaPlayer {
console.log(this.metadata.get(url)); console.log(this.metadata.get(url));
// Notify clients that metadata has been updated // Notify clients that metadata has been updated
this.handleEvent("metadata_update", { this.handleEvent(UserEvent.MetadataUpdate, {
url, url,
metadata: this.metadata.get(url) metadata: this.metadata.get(url)
}); });
@@ -272,7 +282,7 @@ export class MediaPlayer {
this.pendingCommands.delete(response.request_id); this.pendingCommands.delete(response.request_id);
} }
} else if (response.event) { } else if (response.event) {
this.handleEvent(response.event, response); this.handleEvent(UserEvent.MPDUpdate, response);
} else { } else {
console.log(response); console.log(response);
} }

View File

@@ -46,6 +46,15 @@ export interface SearchResponse {
results: SearchResult[]; 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 = { export const API = {
async getPlaylist(): Promise<PlaylistItem[]> { async getPlaylist(): Promise<PlaylistItem[]> {
const response = await fetch('/api/playlist'); const response = await fetch('/api/playlist');

View File

@@ -3,9 +3,9 @@ import SongTable from './SongTable';
import NowPlaying from './NowPlaying'; import NowPlaying from './NowPlaying';
import AddSongPanel from './AddSongPanel'; import AddSongPanel from './AddSongPanel';
import { TabView, Tab } from './TabView'; import { TabView, Tab } from './TabView';
import { API, getDisplayTitle, PlaylistItem } from '../api/player'; import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
import { useEventWebSocket } from '../hooks/useEventWebsocket';
import { FaMusic, FaHeart } from 'react-icons/fa'; import { FaMusic, FaHeart } from 'react-icons/fa';
import useWebSocket from 'react-use-websocket';
enum Tabs { enum Tabs {
Playlist = "playlist", Playlist = "playlist",
@@ -96,14 +96,17 @@ const App: React.FC = () => {
}, []); }, []);
const fetchNowPlaying = useCallback(async () => { 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(); const nowPlaying = await API.getNowPlaying();
setNowPlayingSong(getDisplayTitle(nowPlaying.playingItem)); setNowPlayingSong(getDisplayTitle(nowPlaying.playingItem));
setNowPlayingFileName(nowPlaying.playingItem.filename); setNowPlayingFileName(nowPlaying.playingItem.filename);
setIsPlaying(!nowPlaying.isPaused); setIsPlaying(!nowPlaying.isPaused);
setVolume(nowPlaying.volume);
if (!volumeSettingIsLocked) {
setVolume(nowPlaying.volume);
}
}, [volumeSettingIsLocked]); }, [volumeSettingIsLocked]);
const handleAddURL = async (url: string) => { const handleAddURL = async (url: string) => {
@@ -148,23 +151,41 @@ const App: React.FC = () => {
await API.setVolume(volume); await API.setVolume(volume);
}; };
const handleWebSocketEvent = useCallback((event: any) => { const handleWebSocketEvent = useCallback((message: MessageEvent) => {
const event = JSON.parse(message.data);
switch (event.event) { switch (event.event) {
case 'user_modify': case ServerEvent.PlaylistUpdate:
case 'end-file': case ServerEvent.NowPlayingUpdate:
case 'playback-restart': case ServerEvent.MetadataUpdate:
case 'metadata_update': case ServerEvent.MPDUpdate:
fetchPlaylist(); fetchPlaylist();
fetchNowPlaying(); fetchNowPlaying();
break; break;
case 'favorites_update': case ServerEvent.VolumeUpdate:
if (!volumeSettingIsLocked) {
fetchNowPlaying();
}
break;
case ServerEvent.FavoritesUpdate:
fetchFavorites(); fetchFavorites();
break; break;
} }
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]); }, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
// Use the hook useWebSocket('/api/events', {
useEventWebSocket(handleWebSocketEvent); onOpen: () => {
console.log('WebSocket connected');
},
onClose: () => {
console.log('WebSocket disconnected');
},
onError: (error) => {
console.error('WebSocket error:', error);
},
onMessage: handleWebSocketEvent,
shouldReconnect: () => true,
});
// Handle visibility changes // Handle visibility changes
useEffect(() => { useEffect(() => {

View File

@@ -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<WebSocket | null>(null);
const [wsReconnectTimeout, setWsReconnectTimeout] = useState<number | null>(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;
};

11
package-lock.json generated
View File

@@ -10,7 +10,10 @@
"workspaces": [ "workspaces": [
"backend", "backend",
"frontend" "frontend"
] ],
"dependencies": {
"react-use-websocket": "^4.13.0"
}
}, },
"backend": { "backend": {
"name": "mpvqueue", "name": "mpvqueue",
@@ -4685,6 +4688,12 @@
"node": ">=0.10.0" "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": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",

View File

@@ -8,5 +8,8 @@
"workspaces": [ "workspaces": [
"backend", "backend",
"frontend" "frontend"
] ],
"dependencies": {
"react-use-websocket": "^4.13.0"
}
} }