2 Commits
2.0 ... 2.0.2

Author SHA1 Message Date
acd31a9154 FavoritesStore: fix explicit store path 2025-02-23 16:49:30 -08:00
687a7fc555 Smarter/more granular event handling 2025-02-23 16:37:39 -08:00
7 changed files with 85 additions and 101 deletions

View File

@@ -25,7 +25,7 @@ export class FavoritesStore {
}
// In production (in a container), use /app/data
if (process.env.NODE_ENV === 'production') {
else if (process.env.NODE_ENV === 'production') {
storePath = path.resolve('/app/data');
}
@@ -33,7 +33,9 @@ export class FavoritesStore {
console.error('Failed to create intermediate directory:', err);
});
return path.join(storePath, storeFilename);
const fullPath = path.join(storePath, storeFilename);
console.log("Favorites store path: " + fullPath);
return fullPath;
}
private async loadFavorites() {

View File

@@ -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<T>(func: () => Promise<T>): Promise<T> {
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
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);
}

View File

@@ -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<PlaylistItem[]> {
const response = await fetch('/api/playlist');

View File

@@ -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);
}
}, [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(() => {

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": [
"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",

View File

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