move web components to web/
This commit is contained in:
402
web/frontend/src/components/App.tsx
Normal file
402
web/frontend/src/components/App.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import React, { useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import SongTable from './SongTable';
|
||||
import NowPlaying from './NowPlaying';
|
||||
import AddSongPanel from './AddSongPanel';
|
||||
import RenameFavoriteModal from './RenameFavoriteModal';
|
||||
import { TabView, Tab } from './TabView';
|
||||
import { API, Features, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
|
||||
import { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
import classNames from 'classnames';
|
||||
import { useScreenShare } from '../hooks/useScreenShare';
|
||||
import AudioPlayer from './AudioPlayer';
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
interface SonglistContentProps {
|
||||
songs: PlaylistItem[];
|
||||
isPlaying: boolean;
|
||||
auxControlProvider?: (song: PlaylistItem) => ReactNode;
|
||||
onNeedsRefresh: () => void;
|
||||
}
|
||||
|
||||
const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => {
|
||||
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}
|
||||
onDelete={handleDelete}
|
||||
onSkipTo={handleSkipTo}
|
||||
/>
|
||||
) : (
|
||||
<EmptyContent label="Playlist is empty" />
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => {
|
||||
const handleDelete = (index: number) => {
|
||||
API.removeFromFavorites(songs[index].filename);
|
||||
onNeedsRefresh();
|
||||
};
|
||||
|
||||
const handleSkipTo = (index: number) => {
|
||||
API.replaceCurrentFile(songs[index].filename);
|
||||
API.play();
|
||||
onNeedsRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
songs.length > 0 ? (
|
||||
<SongTable
|
||||
songs={songs}
|
||||
isPlaying={isPlaying}
|
||||
auxControlProvider={auxControlProvider}
|
||||
onDelete={handleDelete}
|
||||
onSkipTo={handleSkipTo}
|
||||
/>
|
||||
) : (
|
||||
<EmptyContent label="Favorites are empty" />
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isIdle, setIsIdle] = useState(false);
|
||||
const [nowPlayingSong, setNowPlayingSong] = useState<string | null>(null);
|
||||
const [nowPlayingFileName, setNowPlayingFileName] = useState<string | null>(null);
|
||||
const [timePosition, setTimePosition] = useState<number | undefined>(undefined);
|
||||
const [duration, setDuration] = useState<number | undefined>(undefined);
|
||||
const [seekable, setSeekable] = useState<boolean | undefined>(undefined);
|
||||
const [volume, setVolume] = useState(100);
|
||||
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
|
||||
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
|
||||
const [favorites, setFavorites] = useState<PlaylistItem[]>([]);
|
||||
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
|
||||
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
||||
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
|
||||
const [audioEnabled, setAudioEnabled] = useState(false);
|
||||
const [features, setFeatures] = useState<Features | null>(null);
|
||||
|
||||
const {
|
||||
isScreenSharing,
|
||||
isScreenSharingSupported,
|
||||
toggleScreenShare,
|
||||
stopScreenShare
|
||||
} = useScreenShare();
|
||||
|
||||
const fetchPlaylist = useCallback(async () => {
|
||||
const playlist = await API.getPlaylist();
|
||||
setPlaylist(playlist);
|
||||
}, []);
|
||||
|
||||
const fetchFavorites = useCallback(async () => {
|
||||
const favorites = await API.getFavorites();
|
||||
setFavorites(favorites);
|
||||
}, []);
|
||||
|
||||
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);
|
||||
setVolume(nowPlaying.volume);
|
||||
setIsIdle(nowPlaying.playingItem ? !nowPlaying.playingItem.playing : true);
|
||||
setTimePosition(nowPlaying.timePosition);
|
||||
setDuration(nowPlaying.duration);
|
||||
setSeekable(nowPlaying.seekable);
|
||||
|
||||
const features = await API.getFeatures();
|
||||
setFeatures(features);
|
||||
}, [volumeSettingIsLocked]);
|
||||
|
||||
const handleAddURL = async (url: string) => {
|
||||
const urlToAdd = url.trim();
|
||||
if (urlToAdd) {
|
||||
if (selectedTab === Tabs.Favorites) {
|
||||
await API.addToFavorites(urlToAdd);
|
||||
fetchFavorites();
|
||||
} else {
|
||||
await API.addToPlaylist(urlToAdd);
|
||||
fetchPlaylist();
|
||||
}
|
||||
|
||||
if (!isPlaying) {
|
||||
await API.play();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlayPause = async () => {
|
||||
if (isPlaying) {
|
||||
await API.pause();
|
||||
} else {
|
||||
await API.play();
|
||||
}
|
||||
|
||||
fetchNowPlaying();
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
stopScreenShare();
|
||||
|
||||
await API.stop();
|
||||
fetchNowPlaying();
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
await API.skip();
|
||||
fetchNowPlaying();
|
||||
};
|
||||
|
||||
const handlePrevious = async () => {
|
||||
await API.previous();
|
||||
fetchNowPlaying();
|
||||
};
|
||||
|
||||
const handleSeek = async (time: number) => {
|
||||
await API.seek(time);
|
||||
fetchNowPlaying();
|
||||
};
|
||||
|
||||
const handleVolumeSettingChange = async (volume: number) => {
|
||||
setVolume(volume);
|
||||
await API.setVolume(volume);
|
||||
};
|
||||
|
||||
const handleWebSocketEvent = useCallback((message: MessageEvent) => {
|
||||
const event = JSON.parse(message.data);
|
||||
switch (event.event) {
|
||||
case ServerEvent.PlaylistUpdate:
|
||||
case ServerEvent.NowPlayingUpdate:
|
||||
case ServerEvent.MetadataUpdate:
|
||||
case ServerEvent.MPDUpdate:
|
||||
fetchPlaylist();
|
||||
fetchNowPlaying();
|
||||
break;
|
||||
case ServerEvent.VolumeUpdate:
|
||||
if (!volumeSettingIsLocked) {
|
||||
fetchNowPlaying();
|
||||
}
|
||||
|
||||
break;
|
||||
case ServerEvent.FavoritesUpdate:
|
||||
fetchFavorites();
|
||||
break;
|
||||
}
|
||||
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
|
||||
|
||||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/events`;
|
||||
useWebSocket(wsUrl, {
|
||||
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(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
fetchPlaylist();
|
||||
fetchNowPlaying();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [fetchPlaylist, fetchNowPlaying]);
|
||||
|
||||
const refreshContent = () => {
|
||||
fetchPlaylist();
|
||||
fetchNowPlaying();
|
||||
fetchFavorites();
|
||||
}
|
||||
|
||||
// Initial data fetch
|
||||
useEffect(() => {
|
||||
fetchPlaylist();
|
||||
fetchNowPlaying();
|
||||
fetchFavorites();
|
||||
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (isPlaying) {
|
||||
fetchNowPlaying();
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isPlaying, fetchNowPlaying]);
|
||||
|
||||
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 (
|
||||
<div className="flex">
|
||||
<AuxButton
|
||||
className="text-white hover:text-white"
|
||||
title="Rename favorite"
|
||||
onClick={() => {
|
||||
setFavoriteToRename(song);
|
||||
setIsRenameModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<FaEdit />
|
||||
</AuxButton>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
{features?.browserPlayback && (
|
||||
<AudioPlayer isPlaying={isPlaying} enabled={audioEnabled} />
|
||||
)}
|
||||
|
||||
<NowPlaying
|
||||
className="flex flex-row md:rounded-t-2xl"
|
||||
songName={nowPlayingSong || "(Not Playing)"}
|
||||
fileName={nowPlayingFileName || ""}
|
||||
isPlaying={isPlaying}
|
||||
isIdle={isIdle}
|
||||
timePosition={timePosition}
|
||||
duration={duration}
|
||||
seekable={seekable}
|
||||
onPlayPause={togglePlayPause}
|
||||
onStop={handleStop}
|
||||
onSkip={handleSkip}
|
||||
onPrevious={handlePrevious}
|
||||
onSeek={handleSeek}
|
||||
onScreenShare={toggleScreenShare}
|
||||
isScreenSharing={isScreenSharing}
|
||||
volume={volume}
|
||||
onVolumeSettingChange={handleVolumeSettingChange}
|
||||
onVolumeWillChange={() => setVolumeSettingIsLocked(true)}
|
||||
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
|
||||
isScreenSharingSupported={isScreenSharingSupported}
|
||||
features={features}
|
||||
audioEnabled={audioEnabled}
|
||||
onAudioEnabledChange={setAudioEnabled}
|
||||
/>
|
||||
|
||||
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>
|
||||
<Tab label="Playlist" identifier={Tabs.Playlist} icon={<FaMusic />}>
|
||||
<PlaylistContent
|
||||
songs={playlist}
|
||||
isPlaying={isPlaying}
|
||||
onNeedsRefresh={refreshContent}
|
||||
auxControlProvider={playlistAuxControlProvider}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab label="Favorites" identifier={Tabs.Favorites} icon={<FaHeart />}>
|
||||
<FavoritesContent
|
||||
songs={favorites.map(f => ({ ...f, playing: f.filename === nowPlayingFileName }))}
|
||||
isPlaying={isPlaying}
|
||||
onNeedsRefresh={refreshContent}
|
||||
auxControlProvider={favoritesAuxControlProvider}
|
||||
/>
|
||||
</Tab>
|
||||
</TabView>
|
||||
|
||||
<AddSongPanel onAddURL={handleAddURL} />
|
||||
|
||||
<RenameFavoriteModal
|
||||
isOpen={isRenameModalOpen}
|
||||
onClose={() => setIsRenameModalOpen(false)}
|
||||
favorite={favoriteToRename}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
Reference in New Issue
Block a user