Files
QueueCube/frontend/src/components/App.tsx

320 lines
9.2 KiB
TypeScript
Raw Normal View History

import React, { useState, useEffect, useCallback, ReactNode } from 'react';
2025-02-15 14:56:58 -08:00
import SongTable from './SongTable';
2025-02-15 15:22:07 -08:00
import NowPlaying from './NowPlaying';
import AddSongPanel from './AddSongPanel';
import { TabView, Tab } from './TabView';
2025-02-23 16:37:39 -08:00
import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
import { FaMusic, FaHeart, FaPlus } from 'react-icons/fa';
2025-02-23 16:37:39 -08:00
import useWebSocket from 'react-use-websocket';
import classNames from 'classnames';
enum Tabs {
Playlist = "playlist",
Favorites = "favorites",
}
const EmptyContent: React.FC<{ label: string}> = ({label}) => (
<div className="flex items-center justify-center h-full">
<div className="text-white text-2xl font-bold">{label}</div>
</div>
);
2025-02-15 16:28:47 -08:00
2025-02-23 13:46:31 -08:00
interface SonglistContentProps {
songs: PlaylistItem[];
isPlaying: boolean;
auxControlProvider?: (song: PlaylistItem) => ReactNode;
2025-02-23 13:46:31 -08:00
onNeedsRefresh: () => void;
}
const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => {
2025-02-23 13:46:31 -08:00
const handleDelete = (index: number) => {
API.removeFromPlaylist(index);
onNeedsRefresh();
};
const handleSkipTo = (index: number) => {
API.skipTo(index);
onNeedsRefresh();
};
return (
songs.length > 0 ? (
<SongTable
songs={songs}
isPlaying={isPlaying}
auxControlProvider={auxControlProvider}
2025-02-23 13:46:31 -08:00
onDelete={handleDelete}
onSkipTo={handleSkipTo}
/>
) : (
<EmptyContent label="Playlist is empty" />
)
);
};
const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => {
2025-02-23 13:46:31 -08:00
const handleDelete = (index: number) => {
API.removeFromFavorites(songs[index].filename);
2025-02-23 13:46:31 -08:00
onNeedsRefresh();
};
const handleSkipTo = (index: number) => {
API.replaceCurrentFile(songs[index].filename);
API.play();
onNeedsRefresh();
};
return (
songs.length > 0 ? (
<SongTable
songs={songs}
isPlaying={isPlaying}
auxControlProvider={auxControlProvider}
2025-02-23 13:46:31 -08:00
onDelete={handleDelete}
onSkipTo={handleSkipTo}
/>
) : (
<EmptyContent label="Favorites are empty" />
)
);
};
2025-02-15 15:22:07 -08:00
const App: React.FC = () => {
const [isPlaying, setIsPlaying] = useState(false);
const [nowPlayingSong, setNowPlayingSong] = useState<string | null>(null);
const [nowPlayingFileName, setNowPlayingFileName] = useState<string | null>(null);
2025-02-15 21:16:04 -08:00
const [volume, setVolume] = useState(100);
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
2025-02-23 13:46:31 -08:00
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
const [favorites, setFavorites] = useState<PlaylistItem[]>([]);
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
2025-02-15 16:28:47 -08:00
2025-02-15 21:16:04 -08:00
const fetchPlaylist = useCallback(async () => {
2025-02-15 16:28:47 -08:00
const playlist = await API.getPlaylist();
2025-02-23 13:46:31 -08:00
setPlaylist(playlist);
}, []);
const fetchFavorites = useCallback(async () => {
const favorites = await API.getFavorites();
setFavorites(favorites);
2025-02-15 21:16:04 -08:00
}, []);
2025-02-15 16:28:47 -08:00
2025-02-15 21:16:04 -08:00
const fetchNowPlaying = useCallback(async () => {
2025-02-23 16:37:39 -08:00
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;
}
2025-02-15 16:28:47 -08:00
const nowPlaying = await API.getNowPlaying();
2025-02-15 22:15:59 -08:00
setNowPlayingSong(getDisplayTitle(nowPlaying.playingItem));
setNowPlayingFileName(nowPlaying.playingItem.filename);
2025-02-15 16:28:47 -08:00
setIsPlaying(!nowPlaying.isPaused);
2025-02-23 16:37:39 -08:00
setVolume(nowPlaying.volume);
2025-02-15 21:16:04 -08:00
}, [volumeSettingIsLocked]);
2025-02-15 12:15:41 -08:00
2025-02-15 22:15:59 -08:00
const handleAddURL = async (url: string) => {
2025-02-15 15:22:07 -08:00
const urlToAdd = url.trim();
if (urlToAdd) {
2025-02-23 13:46:31 -08:00
if (selectedTab === Tabs.Favorites) {
await API.addToFavorites(urlToAdd);
fetchFavorites();
} else {
await API.addToPlaylist(urlToAdd);
fetchPlaylist();
}
2025-02-15 22:15:59 -08:00
if (!isPlaying) {
await API.play();
}
2025-02-15 15:22:07 -08:00
}
};
2025-02-15 12:15:41 -08:00
2025-02-15 16:28:47 -08:00
const togglePlayPause = async () => {
if (isPlaying) {
await API.pause();
} else {
await API.play();
}
fetchNowPlaying();
};
const handleSkip = async () => {
await API.skip();
fetchNowPlaying();
2025-02-15 15:22:07 -08:00
};
2025-02-15 12:15:41 -08:00
2025-02-15 16:28:47 -08:00
const handlePrevious = async () => {
await API.previous();
fetchNowPlaying();
2025-02-15 15:22:07 -08:00
};
2025-02-15 12:15:41 -08:00
2025-02-15 21:16:04 -08:00
const handleVolumeSettingChange = async (volume: number) => {
setVolume(volume);
await API.setVolume(volume);
2025-02-15 16:28:47 -08:00
};
2025-02-23 16:37:39 -08:00
const handleWebSocketEvent = useCallback((message: MessageEvent) => {
const event = JSON.parse(message.data);
2025-02-15 21:16:04 -08:00
switch (event.event) {
2025-02-23 16:37:39 -08:00
case ServerEvent.PlaylistUpdate:
case ServerEvent.NowPlayingUpdate:
case ServerEvent.MetadataUpdate:
case ServerEvent.MPDUpdate:
2025-02-15 21:16:04 -08:00
fetchPlaylist();
fetchNowPlaying();
break;
2025-02-23 16:37:39 -08:00
case ServerEvent.VolumeUpdate:
if (!volumeSettingIsLocked) {
fetchNowPlaying();
}
break;
case ServerEvent.FavoritesUpdate:
2025-02-23 13:46:31 -08:00
fetchFavorites();
break;
2025-02-15 21:16:04 -08:00
}
2025-02-23 13:46:31 -08:00
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
2025-02-15 21:16:04 -08:00
2025-02-23 16:37:39 -08:00
useWebSocket('/api/events', {
onOpen: () => {
console.log('WebSocket connected');
},
onClose: () => {
console.log('WebSocket disconnected');
},
onError: (error) => {
console.error('WebSocket error:', error);
},
onMessage: handleWebSocketEvent,
shouldReconnect: () => true,
});
2025-02-15 21:16:04 -08:00
2025-02-23 12:34:00 -08:00
// Handle visibility changes
2025-02-15 21:16:04 -08:00
useEffect(() => {
const handleVisibilityChange = () => {
2025-02-23 12:34:00 -08:00
if (document.visibilityState === 'visible') {
fetchPlaylist();
fetchNowPlaying();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
2025-02-15 16:28:47 -08:00
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
2025-02-15 16:28:47 -08:00
};
2025-02-23 12:34:00 -08:00
}, [fetchPlaylist, fetchNowPlaying]);
2025-02-23 13:46:31 -08:00
const refreshContent = () => {
fetchPlaylist();
fetchNowPlaying();
fetchFavorites();
}
2025-02-23 12:34:00 -08:00
// Initial data fetch
useEffect(() => {
fetchPlaylist();
fetchNowPlaying();
2025-02-23 13:46:31 -08:00
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>
);
};
2025-02-15 12:15:41 -08:00
return (
2025-02-15 21:16:04 -08:00
<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">
2025-02-15 12:15:41 -08:00
<NowPlaying
className="flex flex-row md:rounded-t-2xl"
2025-02-15 15:22:07 -08:00
songName={nowPlayingSong || "(Not Playing)"}
fileName={nowPlayingFileName || ""}
2025-02-15 12:15:41 -08:00
isPlaying={isPlaying}
2025-02-15 16:28:47 -08:00
onPlayPause={togglePlayPause}
onSkip={handleSkip}
onPrevious={handlePrevious}
2025-02-15 21:16:04 -08:00
volume={volume}
onVolumeSettingChange={handleVolumeSettingChange}
onVolumeWillChange={() => setVolumeSettingIsLocked(true)}
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
2025-02-15 12:15:41 -08:00
/>
2025-02-15 15:22:07 -08:00
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>
<Tab label="Playlist" identifier={Tabs.Playlist} icon={<FaMusic />}>
2025-02-23 13:46:31 -08:00
<PlaylistContent
songs={playlist}
isPlaying={isPlaying}
onNeedsRefresh={refreshContent}
auxControlProvider={playlistAuxControlProvider}
2025-02-23 13:46:31 -08:00
/>
</Tab>
<Tab label="Favorites" identifier={Tabs.Favorites} icon={<FaHeart />}>
2025-02-23 13:46:31 -08:00
<FavoritesContent
songs={favorites.map(f => ({ ...f, playing: f.filename === nowPlayingFileName }))}
isPlaying={isPlaying}
onNeedsRefresh={refreshContent}
auxControlProvider={favoritesAuxControlProvider}
2025-02-23 13:46:31 -08:00
/>
</Tab>
</TabView>
2025-02-15 15:22:07 -08:00
<AddSongPanel onAddURL={handleAddURL} />
2025-02-15 12:15:41 -08:00
</div>
</div>
);
};
export default App;