move web components to web/

This commit is contained in:
2025-10-10 23:13:03 -07:00
parent 623b562e8d
commit 52968df567
46 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
import React, { useState, KeyboardEvent, ChangeEvent } from 'react';
import { FaSearch } from 'react-icons/fa';
import InvidiousSearchModal from './InvidiousSearchModal';
import { USE_INVIDIOUS } from '../config';
interface AddSongPanelProps {
onAddURL: (url: string) => void;
}
const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => {
const [url, setUrl] = useState('');
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const handleAddURL = () => {
onAddURL(url);
setUrl('');
}
return (
<div className="flex items-center justify-center h-fit bg-black/50 md:rounded-b-2xl text-white">
<div className="flex flex-row items-center gap-4 w-full px-8 py-4">
{USE_INVIDIOUS && (
<button
className="bg-violet-500/20 text-white p-2 rounded-lg border-2 border-violet-500"
onClick={() => setIsSearchModalOpen(true)}
>
<FaSearch />
</button>
)}
<input
type="text"
value={url}
onChange={(e: ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)}
placeholder="Add any URL..."
className="p-2 rounded-lg border-2 border-violet-500 flex-grow"
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleAddURL();
}
}}
/>
<button
className="bg-violet-500 text-white p-2 rounded-lg px-4 border-2 border-violet-500"
onClick={handleAddURL}
>
Add
</button>
</div>
<InvidiousSearchModal
isOpen={isSearchModalOpen}
onClose={() => setIsSearchModalOpen(false)}
onSelectVideo={(videoUrl) => {
onAddURL(videoUrl);
setIsSearchModalOpen(false);
}}
/>
</div>
);
};
export default AddSongPanel;

View 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;

View File

@@ -0,0 +1,32 @@
import React, { useEffect, useRef } from 'react';
interface AudioPlayerProps {
isPlaying: boolean;
enabled: boolean;
}
const AudioPlayer: React.FC<AudioPlayerProps> = ({ isPlaying, enabled }) => {
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
if (enabled && isPlaying) {
console.log("Playing audio");
audioRef.current?.play().catch((error) => {
console.error("Audio playback error:", error);
});
} else {
console.log("Pausing audio");
audioRef.current?.pause();
}
}, [isPlaying, enabled]);
return (
<audio
ref={audioRef}
src="/stream/audio.m3u8"
preload="metadata"
/>
);
};
export default AudioPlayer;

View File

@@ -0,0 +1,111 @@
import React, { useState, KeyboardEvent } from 'react';
import { FaSearch, FaSpinner, FaTimes } from 'react-icons/fa';
import { API, SearchResult } from '../api/player';
interface InvidiousSearchModalProps {
isOpen: boolean;
onClose: () => void;
onSelectVideo: (url: string) => void;
}
const ResultCell: React.FC<{ result: SearchResult, onClick: () => void }> = ({ result, onClick, ...props }) => {
return (
<div className="flex gap-4 bg-black/20 p-2 rounded-lg cursor-pointer hover:bg-black/30 transition-colors" onClick={onClick} {...props}>
<img src={result.thumbnailUrl} alt={result.title} className="w-32 h-18 object-cover rounded" />
<div className="flex flex-col justify-center">
<h3 className="text-white font-semibold">{result.title}</h3>
<p className="text-white/60">{result.author}</p>
</div>
</div>
);
};
const InvidiousSearchModal: React.FC<InvidiousSearchModalProps> = ({ isOpen, onClose, onSelectVideo }) => {
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const handleSearch = async () => {
if (!searchQuery.trim()) return;
setIsLoading(true);
try {
const response = await API.search(searchQuery);
if (response.success) {
setResults(response.results);
} else {
console.error('Search failed:', response);
}
} catch (error) {
console.error('Failed to search:', error);
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const _onSelectVideo = (url: string) => {
setSearchQuery('');
setResults([]);
onSelectVideo(url);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-violet-900 w-full max-w-xl rounded-lg p-4 shadow-lg">
<div className="flex justify-between items-center mb-4">
<h2 className="text-white text-xl font-bold">Search YouTube (Invidious)</h2>
<button onClick={onClose} className="text-white/60 hover:text-white">
<FaTimes size={24} />
</button>
</div>
<div className="flex gap-2 mb-4">
<input
type="text"
autoFocus
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search videos..."
className="p-2 rounded-lg border-2 border-violet-500 flex-grow bg-black/20 text-white"
/>
<button
onClick={handleSearch}
disabled={isLoading}
className="bg-violet-500 text-white p-2 rounded-lg px-4 border-2 border-violet-500 disabled:opacity-50"
>
{isLoading ? <span className="animate-spin"><FaSpinner /></span> : <span><FaSearch /></span>}
</button>
</div>
<div className="max-h-[60vh] overflow-y-auto">
{isLoading ? (
<div className="text-white text-center py-12">Searching...</div>
) : (
<div className="grid gap-4">
{results.map((result) => (
<ResultCell
key={result.mediaUrl}
result={result}
onClick={() => _onSelectVideo(result.mediaUrl)}
/>
))}
</div>
)}
</div>
</div>
</div>
);
};
export default InvidiousSearchModal;

View File

@@ -0,0 +1,176 @@
import React, { HTMLAttributes, useState, useRef } from 'react';
import classNames from 'classnames';
import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaVolumeUp, FaDesktop, FaStop } from 'react-icons/fa';
import { Features } from '../api/player';
interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
songName: string;
fileName: string;
isPlaying: boolean;
isIdle: boolean;
volume: number;
timePosition?: number;
duration?: number;
seekable?: boolean;
onPlayPause: () => void;
onStop: () => void;
onSkip: () => void;
onPrevious: () => void;
onSeek: (time: number) => void;
onScreenShare: () => void;
isScreenSharingSupported: boolean;
isScreenSharing: boolean;
// Sent when the volume setting actually changes value
onVolumeSettingChange: (volume: number) => void;
// Sent when the volume is about to start changing
onVolumeWillChange: (volume: number) => void;
// Sent when the volume has changed
onVolumeDidChange: (volume: number) => void;
features: Features | null;
audioEnabled: boolean;
onAudioEnabledChange: (enabled: boolean) => void;
}
const NowPlaying: React.FC<NowPlayingProps> = (props) => {
const [isSeeking, setIsSeeking] = useState(false);
const progressBarRef = useRef<HTMLDivElement>(null);
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (progressBarRef.current) {
const rect = progressBarRef.current.getBoundingClientRect();
const newSeekPosition = (e.clientX - rect.left) / rect.width;
if (props.duration) {
props.onSeek(newSeekPosition * props.duration);
}
}
};
const titleArea = props.isScreenSharing ? (
<div className="flex flex-row items-center gap-2 text-white text-center justify-center">
<FaDesktop size={24} />
<div className="text-lg font-bold truncate">Screen Sharing</div>
</div>
) : (
<div className={classNames(props.isIdle ? 'opacity-50' : 'opacity-100', "flex flex-row items-center justify-between gap-2 w-full")}>
<div className="truncate">
<div className="text-lg font-bold truncate">{props.songName}</div>
<div className="text-sm truncate">{props.fileName}</div>
</div>
<div className="text-sm opacity-50 shrink-0">
{props.timePosition && props.duration ?
(props.seekable ? `${formatTime(props.timePosition)} / ${formatTime(props.duration)}`
: `${formatTime(props.timePosition)}` )
: ''}
</div>
</div>
);
return (
<div className={classNames(props.className, 'bg-black/50 h-fit p-5')}>
<div className="flex flex-col w-full gap-2">
<div className="flex flex-col w-full h-full bg-black/50 rounded-lg gap-4 overflow-hidden">
<div className="p-5">
<div className="flex-grow min-w-0 w-full text-white text-left">
{titleArea}
</div>
<div className="flex flex-row items-center gap-4 w-full pt-4">
<div className="flex items-center gap-2 text-white w-full max-w-[250px]">
<FaVolumeUp size={20} />
<input
type="range"
min="0"
max="100"
value={props.volume}
onMouseDown={() => props.onVolumeWillChange(props.volume)}
onMouseUp={() => props.onVolumeDidChange(props.volume)}
onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))}
className="fancy-slider h-2 w-full"
/>
</div>
<div className="flex-grow"></div>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPrevious}>
<FaStepBackward size={24} />
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPlayPause}>
{(props.isPlaying && !props.isIdle) ? <FaPause size={24} /> : <FaPlay size={24} />}
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onStop}>
<FaStop size={24} className={props.isIdle ? 'opacity-25' : 'opacity-100'} />
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onSkip}>
<FaStepForward size={24} />
</button>
{(props.isScreenSharingSupported && props.features?.screenshare) && (
<button
className={classNames("text-white hover:text-violet-300 transition-colors rounded-full p-2", props.isScreenSharing ? ' bg-violet-800' : '')}
onClick={props.onScreenShare}
title="Share your screen"
>
<FaDesktop size={24} />
</button>
)}
</div>
</div>
{props.seekable !== false && (
<div
ref={progressBarRef}
className="w-full h-2 bg-gray-600 cursor-pointer -mt-3"
onMouseDown={(e) => {
setIsSeeking(true);
handleSeek(e);
}}
onMouseMove={(e) => {
if (isSeeking) {
handleSeek(e);
}
}}
onMouseUp={() => setIsSeeking(false)}
onMouseLeave={() => setIsSeeking(false)}
>
<div
className="h-full bg-violet-500"
style={{ width: `${(props.timePosition && props.duration ? (props.timePosition / props.duration) * 100 : 0)}%` }}
/>
</div>
)}
{props.features?.browserPlayback && (
<div>
<label className="flex items-center gap-2 text-white text-sm cursor-pointer">
<input
type="checkbox"
checked={props.audioEnabled}
onChange={(e) => props.onAudioEnabledChange(e.target.checked)}
className="w-4 h-4 text-violet-600 bg-gray-100 border-gray-300 rounded focus:ring-violet-500 focus:ring-2"
/>
Enable audio playback in browser
</label>
</div>
)}
</div>
</div>
</div>
);
};
export default NowPlaying;

View File

@@ -0,0 +1,97 @@
import React, { useState, KeyboardEvent, useEffect } from 'react';
import { FaTimes, FaCheck } from 'react-icons/fa';
import { API, PlaylistItem, getDisplayTitle } from '../api/player';
interface RenameFavoriteModalProps {
isOpen: boolean;
onClose: () => void;
favorite: PlaylistItem | null;
}
const RenameFavoriteModal: React.FC<RenameFavoriteModalProps> = ({ isOpen, onClose, favorite }) => {
const [title, setTitle] = useState('');
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (favorite) {
setTitle(getDisplayTitle(favorite));
}
}, [favorite]);
const handleSave = async () => {
if (!favorite || !title.trim()) return;
setIsSaving(true);
try {
await API.updateFavoriteTitle(favorite.filename, title);
onClose();
} catch (error) {
console.error('Failed to rename favorite:', error);
} finally {
setIsSaving(false);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSave();
} else if (e.key === 'Escape') {
onClose();
}
};
if (!isOpen || !favorite) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-violet-900 w-full max-w-md rounded-lg p-4 shadow-lg">
<div className="flex justify-between items-center mb-4">
<h2 className="text-white text-xl font-bold">Rename Favorite</h2>
<button onClick={onClose} className="text-white/60 hover:text-white">
<FaTimes size={24} />
</button>
</div>
<div className="mb-4">
<label className="text-white/80 block mb-2">Original filename:</label>
<div className="text-white/60 text-sm truncate bg-black/20 p-2 rounded-lg mb-4">
{favorite.filename}
</div>
<label className="text-white/80 block mb-2">Title:</label>
<input
type="text"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter new title..."
className="p-2 rounded-lg border-2 border-violet-500 w-full bg-black/20 text-white"
/>
</div>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="bg-black/30 text-white p-2 rounded-lg px-4 hover:bg-black/40"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isSaving || !title.trim()}
className="bg-violet-500 text-white p-2 rounded-lg px-4 disabled:opacity-50 flex items-center gap-2"
>
<FaCheck size={16} />
Save
</button>
</div>
</div>
</div>
);
};
export default RenameFavoriteModal;

View File

@@ -0,0 +1,103 @@
import classNames from 'classnames';
import React, { useState, useRef, useEffect, ReactNode } from 'react';
import { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa';
import { getDisplayTitle, PlaylistItem } from '../api/player';
export enum PlayState {
NotPlaying,
Playing,
Paused,
}
export interface SongRowProps {
song: PlaylistItem;
auxControl?: ReactNode;
playState: PlayState;
onDelete: () => void;
onPlay: () => void;
}
const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete, onPlay }) => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
setShowDeleteConfirm(false);
}
};
if (showDeleteConfirm) {
document.addEventListener('click', handleClickOutside);
}
return () => {
document.removeEventListener('click', handleClickOutside);
};
}, [showDeleteConfirm]);
const displayTitle = getDisplayTitle(song);
return (
<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),
"bg-black/30": playState === PlayState.NotPlaying,
})}>
<div className="flex flex-row gap-2">
<button
className="text-white/40 hover:text-white transition-colors px-3 py-1 rounded"
onClick={onPlay}
>
{
playState === PlayState.Playing ? <FaVolumeUp size={12} />
: playState === PlayState.Paused ? <FaVolumeOff size={12} />
: <FaPlay size={12} />
}
</button>
</div>
<div className="flex-grow min-w-0">
{
displayTitle ? (
<div>
<div className="text-white text-md truncate text-bold">
{displayTitle}
</div>
<div className="text-white/80 text-xs truncate">
{song.filename}
</div>
</div>
) : (
<div className="text-white text-md truncate text-bold">
{song.filename}
</div>
)
}
</div>
<div className="flex flex-row gap-2">
{auxControl}
<button
ref={buttonRef}
className="text-red-100 px-3 py-1 bg-red-500/40 rounded"
onClick={(e) => {
e.stopPropagation();
if (showDeleteConfirm) {
setShowDeleteConfirm(false);
onDelete();
} else {
setShowDeleteConfirm(true);
}
}}
>
{showDeleteConfirm ? 'Delete' : '×'}
</button>
</div>
</div>
);
};
export default SongRow;

View File

@@ -0,0 +1,43 @@
import React, { useEffect, useRef, ReactNode } from "react";
import SongRow, { PlayState } from "./SongRow";
import { PlaylistItem } from "../api/player";
interface SongTableProps {
songs: PlaylistItem[];
isPlaying: boolean
auxControlProvider?: (song: PlaylistItem) => ReactNode;
onDelete: (index: number) => void;
onSkipTo: (index: number) => void;
}
const SongTable: React.FC<SongTableProps> = ({ songs, isPlaying, auxControlProvider, onDelete, onSkipTo }) => {
const nowPlayingIndex = songs.findIndex(song => song.playing ?? false);
const songTableRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const songTable = songTableRef.current;
if (songTable) {
songTable.scrollTop = nowPlayingIndex * 100;
}
}, [nowPlayingIndex]);
return (
<div className="flex flex-col w-full h-full overflow-y-auto" ref={songTableRef}>
{songs.map((song, index) => (
<SongRow
key={index}
song={song}
auxControl={auxControlProvider ? auxControlProvider(song) : undefined}
playState={
(song.playing ?? false) ? (isPlaying ? PlayState.Playing : PlayState.Paused)
: PlayState.NotPlaying
}
onDelete={() => onDelete(index)}
onPlay={() => onSkipTo(index)}
/>
))}
</div>
);
};
export default SongTable;

View File

@@ -0,0 +1,64 @@
import React, { ReactNode } from 'react';
import classNames from 'classnames';
interface TabProps<T> {
label: string;
identifier: T;
icon?: ReactNode;
children: ReactNode;
}
export const Tab = <T,>({ children }: TabProps<T>) => {
// Wrapper component
return <>{children}</>;
};
interface TabViewProps<T> {
children: ReactNode;
selectedTab: T;
onTabChange: (tab: T) => void;
}
export const TabView = <T,>({ children, selectedTab, onTabChange }: TabViewProps<T>) => {
// Filter and validate children to only get Tab components
const tabs = React.Children.toArray(children).filter(
(child) => React.isValidElement(child) && child.type === Tab
) as React.ReactElement<TabProps<T>>[];
return (
<div className="flex flex-col flex-1 overflow-hidden">
<div className="flex flex-row h-11 border-b border-white/20">
{tabs.map((tab, index) => {
const isSelected = selectedTab === tab.props.identifier;
const rowClassName = classNames(
"flex flex-row items-center justify-center w-full gap-2 text-white",
{ "qc-highlighted": isSelected }
);
return (
<div
key={index}
className={rowClassName}
onClick={() => onTabChange(tab.props.identifier)}
>
{tab.props.icon}
<div className="text-sm font-bold">{tab.props.label}</div>
</div>
);
})}
</div>
<div className="flex-1 overflow-hidden">
{tabs.map((tab, index) => (
<div
key={index}
className={classNames("w-full h-full", {
hidden: selectedTab !== tab.props.identifier,
})}
>
{tab.props.children}
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { default as App } from './App';