7 Commits
2.0 ... 2.2

13 changed files with 210 additions and 166 deletions

View File

@@ -1 +0,0 @@
[]

View File

@@ -25,7 +25,7 @@ export class FavoritesStore {
} }
// In production (in a container), use /app/data // 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'); storePath = path.resolve('/app/data');
} }
@@ -33,7 +33,9 @@ export class FavoritesStore {
console.error('Failed to create intermediate directory:', err); 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() { private async loadFavorites() {
@@ -59,49 +61,48 @@ export class FavoritesStore {
return this.favorites; return this.favorites;
} }
async addFavorite(item: PlaylistItem, fetchMetadata: boolean = true): Promise<void> { async addFavorite(filename: string): Promise<void> {
// Check if the item already exists by filename // Check if the item already exists by filename
const exists = this.favorites.some(f => f.filename === item.filename); const exists = this.favorites.some(f => f.filename === filename);
if (!exists) { if (!exists) {
this.favorites.push({ this.favorites.push({
...item, filename: filename,
id: this.favorites.length // Generate new ID id: this.favorites.length // Generate new ID
}); });
await this.saveFavorites(); await this.saveFavorites();
} else {
// Otherwise, update the item with the new metadata
const index = this.favorites.findIndex(f => f.filename === item.filename);
this.favorites[index] = {
...this.favorites[index],
...item,
};
await this.saveFavorites();
}
// If the item is missing metadata, fetch it // Fetch metadata for the new favorite
if (fetchMetadata && !item.metadata) { await this.fetchMetadata(filename);
await this.fetchMetadata(item);
} }
} }
private async fetchMetadata(item: PlaylistItem): Promise<void> { private async fetchMetadata(filename: string): Promise<void> {
console.log("Fetching metadata for " + item.filename); console.log("Fetching metadata for " + filename);
const metadata = await getLinkPreview(item.filename); const metadata = await getLinkPreview(filename);
item.metadata = { const item: PlaylistItem = {
title: (metadata as any)?.title, filename: filename,
description: (metadata as any)?.description, id: this.favorites.length,
siteName: (metadata as any)?.siteName, metadata: {
title: (metadata as any)?.title,
description: (metadata as any)?.description,
siteName: (metadata as any)?.siteName,
},
}; };
console.log("Metadata fetched for " + item.filename); console.log("Metadata fetched for " + item.filename);
console.log(item); console.log(item);
await this.addFavorite(item, false); const index = this.favorites.findIndex(f => f.filename === filename);
if (index !== -1) {
this.favorites[index] = item;
await this.saveFavorites();
}
} }
async removeFavorite(id: number): Promise<void> { async removeFavorite(filename: string): Promise<void> {
this.favorites = this.favorites.filter(f => f.id !== id); console.log("Removing favorite " + filename);
this.favorites = this.favorites.filter(f => f.filename !== filename);
await this.saveFavorites(); await this.saveFavorites();
} }

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;
@@ -23,10 +33,12 @@ export class MediaPlayer {
constructor() { constructor() {
const socketFilename = Math.random().toString(36).substring(2, 10); const socketFilename = Math.random().toString(36).substring(2, 10);
const socketPath = `/tmp/mpv-${socketFilename}`; const socketPath = `/tmp/mpv-${socketFilename}`;
const enableVideo = process.env.ENABLE_VIDEO || false;
console.log("Starting player process"); console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
this.playerProcess = spawn("mpv", [ this.playerProcess = spawn("mpv", [
"--no-video", "--video=" + (enableVideo ? "auto" : "no"),
"--fullscreen",
"--no-terminal", "--no-terminal",
"--idle=yes", "--idle=yes",
"--input-ipc-server=" + socketPath "--input-ipc-server=" + socketPath
@@ -43,7 +55,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 +135,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) {
@@ -162,12 +174,12 @@ export class MediaPlayer {
return this.favoritesStore.getFavorites(); return this.favoritesStore.getFavorites();
} }
public async addFavorite(item: PlaylistItem) { public async addFavorite(filename: string) {
return this.favoritesStore.addFavorite(item); return this.favoritesStore.addFavorite(filename);
} }
public async removeFavorite(id: number) { public async removeFavorite(filename: string) {
return this.favoritesStore.removeFavorite(id); return this.favoritesStore.removeFavorite(filename);
} }
public async clearFavorites() { public async clearFavorites() {
@@ -175,18 +187,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 +242,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 +284,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

@@ -140,14 +140,15 @@ apiRouter.get("/favorites", withErrorHandling(async (req, res) => {
})); }));
apiRouter.post("/favorites", withErrorHandling(async (req, res) => { apiRouter.post("/favorites", withErrorHandling(async (req, res) => {
const item = req.body as PlaylistItem; const { filename } = req.body as { filename: string };
await mediaPlayer.addFavorite(item); console.log("Adding favorite: " + filename);
await mediaPlayer.addFavorite(filename);
res.send(JSON.stringify({ success: true })); res.send(JSON.stringify({ success: true }));
})); }));
apiRouter.delete("/favorites/:id", withErrorHandling(async (req, res) => { apiRouter.delete("/favorites/:filename", withErrorHandling(async (req, res) => {
const { id } = req.params; const { filename } = req.params as { filename: string };
await mediaPlayer.removeFavorite(parseInt(id)); await mediaPlayer.removeFavorite(filename);
res.send(JSON.stringify({ success: true })); res.send(JSON.stringify({ success: true }));
})); }));

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');
@@ -133,28 +142,18 @@ export const API = {
return response.json(); return response.json();
}, },
async addToFavorites(url: string): Promise<void> { async addToFavorites(filename: string): Promise<void> {
// Maybe a little weird to make an empty PlaylistItem here, but we do want to support adding
// known PlaylistItems to favorites in the future.
const playlistItem: PlaylistItem = {
filename: url,
title: null,
id: 0,
playing: null,
metadata: undefined,
};
await fetch('/api/favorites', { await fetch('/api/favorites', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(playlistItem), body: JSON.stringify({ filename }),
}); });
}, },
async removeFromFavorites(id: number): Promise<void> { async removeFromFavorites(filename: string): Promise<void> {
await fetch(`/api/favorites/${id}`, { method: 'DELETE' }); await fetch(`/api/favorites/${encodeURIComponent(filename)}`, { method: 'DELETE' });
}, },
async clearFavorites(): Promise<void> { async clearFavorites(): Promise<void> {

View File

@@ -2,6 +2,7 @@ import React, { useState, KeyboardEvent, ChangeEvent } from 'react';
import { FaSearch } from 'react-icons/fa'; import { FaSearch } from 'react-icons/fa';
import InvidiousSearchModal from './InvidiousSearchModal'; import InvidiousSearchModal from './InvidiousSearchModal';
import { USE_INVIDIOUS } from '../config'; import { USE_INVIDIOUS } from '../config';
interface AddSongPanelProps { interface AddSongPanelProps {
onAddURL: (url: string) => void; onAddURL: (url: string) => void;
} }

View File

@@ -1,11 +1,12 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, ReactNode } from 'react';
import SongTable from './SongTable'; 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, FaPlus } from 'react-icons/fa';
import { FaMusic, FaHeart } from 'react-icons/fa'; import useWebSocket from 'react-use-websocket';
import classNames from 'classnames';
enum Tabs { enum Tabs {
Playlist = "playlist", Playlist = "playlist",
@@ -21,10 +22,11 @@ const EmptyContent: React.FC<{ label: string}> = ({label}) => (
interface SonglistContentProps { interface SonglistContentProps {
songs: PlaylistItem[]; songs: PlaylistItem[];
isPlaying: boolean; isPlaying: boolean;
auxControlProvider?: (song: PlaylistItem) => ReactNode;
onNeedsRefresh: () => void; onNeedsRefresh: () => void;
} }
const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onNeedsRefresh }) => { const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => {
const handleDelete = (index: number) => { const handleDelete = (index: number) => {
API.removeFromPlaylist(index); API.removeFromPlaylist(index);
onNeedsRefresh(); onNeedsRefresh();
@@ -40,6 +42,7 @@ const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onN
<SongTable <SongTable
songs={songs} songs={songs}
isPlaying={isPlaying} isPlaying={isPlaying}
auxControlProvider={auxControlProvider}
onDelete={handleDelete} onDelete={handleDelete}
onSkipTo={handleSkipTo} onSkipTo={handleSkipTo}
/> />
@@ -49,9 +52,9 @@ const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onN
); );
}; };
const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onNeedsRefresh }) => { const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => {
const handleDelete = (index: number) => { const handleDelete = (index: number) => {
API.removeFromFavorites(index); API.removeFromFavorites(songs[index].filename);
onNeedsRefresh(); onNeedsRefresh();
}; };
@@ -66,6 +69,7 @@ const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, on
<SongTable <SongTable
songs={songs} songs={songs}
isPlaying={isPlaying} isPlaying={isPlaying}
auxControlProvider={auxControlProvider}
onDelete={handleDelete} onDelete={handleDelete}
onSkipTo={handleSkipTo} onSkipTo={handleSkipTo}
/> />
@@ -96,14 +100,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 +155,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(() => {
@@ -194,6 +219,62 @@ const App: React.FC = () => {
fetchFavorites(); fetchFavorites();
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]); }, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
const AuxButton: React.FC<{ children: ReactNode, className: string, title: string, onClick: () => void }> = (props) => (
<button
className={
classNames("hover:text-white transition-colors px-3 py-1 rounded", props.className)
}
title={props.title}
onClick={props.onClick}
>
{props.children}
</button>
);
const playlistAuxControlProvider = (song: PlaylistItem) => {
const isFavorite = favorites.some(f => f.filename === song.filename);
return (
<AuxButton
className={classNames({
"text-red-500": isFavorite,
"text-white/40": !isFavorite,
})}
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
onClick={() => {
if (isFavorite) {
API.removeFromFavorites(song.filename);
} else {
API.addToFavorites(song.filename);
}
}}
>
<FaHeart />
</AuxButton>
);
};
const favoritesAuxControlProvider = (song: PlaylistItem) => {
const isInPlaylist = playlist.some(p => p.filename === song.filename);
return (
<AuxButton
className={classNames({
"text-white/40": isInPlaylist,
"text-white": !isInPlaylist,
})}
title={isInPlaylist ? "Remove from playlist" : "Add to playlist"}
onClick={() => {
if (isInPlaylist) {
API.removeFromPlaylist(playlist.findIndex(p => p.filename === song.filename));
} else {
API.addToPlaylist(song.filename);
}
}}
>
<FaPlus />
</AuxButton>
);
};
return ( return (
<div className="flex items-center justify-center h-screen w-screen bg-black md:py-10"> <div className="flex items-center justify-center h-screen w-screen bg-black md:py-10">
<div className="bg-violet-900 w-full md:max-w-2xl h-full md:max-h-xl md:border md:rounded-2xl flex flex-col"> <div className="bg-violet-900 w-full md:max-w-2xl h-full md:max-h-xl md:border md:rounded-2xl flex flex-col">
@@ -217,6 +298,7 @@ const App: React.FC = () => {
songs={playlist} songs={playlist}
isPlaying={isPlaying} isPlaying={isPlaying}
onNeedsRefresh={refreshContent} onNeedsRefresh={refreshContent}
auxControlProvider={playlistAuxControlProvider}
/> />
</Tab> </Tab>
<Tab label="Favorites" identifier={Tabs.Favorites} icon={<FaHeart />}> <Tab label="Favorites" identifier={Tabs.Favorites} icon={<FaHeart />}>
@@ -224,6 +306,7 @@ const App: React.FC = () => {
songs={favorites.map(f => ({ ...f, playing: f.filename === nowPlayingFileName }))} songs={favorites.map(f => ({ ...f, playing: f.filename === nowPlayingFileName }))}
isPlaying={isPlaying} isPlaying={isPlaying}
onNeedsRefresh={refreshContent} onNeedsRefresh={refreshContent}
auxControlProvider={favoritesAuxControlProvider}
/> />
</Tab> </Tab>
</TabView> </TabView>

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect, ReactNode } from 'react';
import { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa'; import { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa';
import { getDisplayTitle, PlaylistItem } from '../api/player'; import { getDisplayTitle, PlaylistItem } from '../api/player';
@@ -11,12 +11,13 @@ export enum PlayState {
export interface SongRowProps { export interface SongRowProps {
song: PlaylistItem; song: PlaylistItem;
auxControl?: ReactNode;
playState: PlayState; playState: PlayState;
onDelete: () => void; onDelete: () => void;
onPlay: () => void; onPlay: () => void;
} }
const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay }) => { const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete, onPlay }) => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
@@ -39,7 +40,8 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
const displayTitle = getDisplayTitle(song); const displayTitle = getDisplayTitle(song);
return ( return (
<div className={classNames("flex flex-row w-full h-24 px-2 py-5 items-center border-b gap-2 transition-colors", { <div className={classNames(
"flex flex-row w-full h-20 px-2 py-2 items-center border-b gap-2 transition-colors shrink-0", {
"qc-highlighted": (playState === PlayState.Playing || playState === PlayState.Paused), "qc-highlighted": (playState === PlayState.Playing || playState === PlayState.Paused),
"bg-black/30": playState === PlayState.NotPlaying, "bg-black/30": playState === PlayState.NotPlaying,
})}> })}>
@@ -76,6 +78,8 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
</div> </div>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
{auxControl}
<button <button
ref={buttonRef} ref={buttonRef}
className="text-red-100 px-3 py-1 bg-red-500/40 rounded" className="text-red-100 px-3 py-1 bg-red-500/40 rounded"

View File

@@ -1,15 +1,16 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef, ReactNode } from "react";
import SongRow, { PlayState } from "./SongRow"; import SongRow, { PlayState } from "./SongRow";
import { PlaylistItem } from "../api/player"; import { PlaylistItem } from "../api/player";
interface SongTableProps { interface SongTableProps {
songs: PlaylistItem[]; songs: PlaylistItem[];
isPlaying: boolean; isPlaying: boolean
auxControlProvider?: (song: PlaylistItem) => ReactNode;
onDelete: (index: number) => void; onDelete: (index: number) => void;
onSkipTo: (index: number) => void; onSkipTo: (index: number) => void;
} }
const SongTable: React.FC<SongTableProps> = ({ songs, isPlaying, onDelete, onSkipTo }) => { const SongTable: React.FC<SongTableProps> = ({ songs, isPlaying, auxControlProvider, onDelete, onSkipTo }) => {
const nowPlayingIndex = songs.findIndex(song => song.playing ?? false); const nowPlayingIndex = songs.findIndex(song => song.playing ?? false);
const songTableRef = useRef<HTMLDivElement>(null); const songTableRef = useRef<HTMLDivElement>(null);
@@ -21,11 +22,12 @@ const SongTable: React.FC<SongTableProps> = ({ songs, isPlaying, onDelete, onSki
}, [nowPlayingIndex]); }, [nowPlayingIndex]);
return ( return (
<div className="flex flex-col w-full h-full overflow-y-auto border-y" ref={songTableRef}> <div className="flex flex-col w-full h-full overflow-y-auto" ref={songTableRef}>
{songs.map((song, index) => ( {songs.map((song, index) => (
<SongRow <SongRow
key={index} key={index}
song={song} song={song}
auxControl={auxControlProvider ? auxControlProvider(song) : undefined}
playState={ playState={
(song.playing ?? false) ? (isPlaying ? PlayState.Playing : PlayState.Paused) (song.playing ?? false) ? (isPlaying ? PlayState.Playing : PlayState.Paused)
: PlayState.NotPlaying : PlayState.NotPlaying

View File

@@ -26,7 +26,7 @@ export const TabView = <T,>({ children, selectedTab, onTabChange }: TabViewProps
) as React.ReactElement<TabProps<T>>[]; ) as React.ReactElement<TabProps<T>>[];
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col flex-1 overflow-hidden">
<div className="flex flex-row h-11 border-b border-white/20"> <div className="flex flex-row h-11 border-b border-white/20">
{tabs.map((tab, index) => { {tabs.map((tab, index) => {
const isSelected = selectedTab === tab.props.identifier; const isSelected = selectedTab === tab.props.identifier;
@@ -47,7 +47,7 @@ export const TabView = <T,>({ children, selectedTab, onTabChange }: TabViewProps
); );
})} })}
</div> </div>
<div className="flex-1"> <div className="flex-1 overflow-hidden">
{tabs.map((tab, index) => ( {tabs.map((tab, index) => (
<div <div
key={index} key={index}

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"
}
} }