feat: add search functionality to find artists, albums, and songs with improved UI and results display
This commit is contained in:
@@ -108,36 +108,42 @@ export default function ArtistPage() {
|
||||
: '/default-user.jpg';
|
||||
|
||||
return (
|
||||
<div className="h-full px-4 py-6 lg:px-8">
|
||||
<div className="h-full px-4 py-6 lg:px-8 pb-24">
|
||||
<div className="space-y-6">
|
||||
{/* Artist Header */}
|
||||
<div className="relative bg-gradient-to-r from-blue-900 to-purple-900 rounded-lg p-8">
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="relative">
|
||||
<Image
|
||||
src={artistImageUrl}
|
||||
alt={artist.name}
|
||||
width={120}
|
||||
height={120}
|
||||
className="rounded-full border-4 border-white shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-4xl font-bold text-white mb-2">{artist.name}</h1>
|
||||
<p className="text-white/80 mb-4">{artist.albumCount} albums</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handlePlayArtist}
|
||||
disabled={isPlayingArtist}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{isPlayingArtist ? 'Adding to Queue...' : 'Play Artist'}
|
||||
</Button>
|
||||
<Button onClick={handleStar} variant="secondary">
|
||||
<Heart className={isStarred ? 'text-red-500' : 'text-gray-500'} fill={isStarred ? 'red' : 'none'}/>
|
||||
{isStarred ? 'Starred' : 'Star Artist'}
|
||||
</Button>
|
||||
<div className="relative rounded-lg p-8">
|
||||
<div className="relative rounded-sm p-10">
|
||||
<div
|
||||
className="absolute inset-0 bg-center bg-cover bg-no-repeat blur-xl"
|
||||
style={{ backgroundImage: `url(${artistImageUrl})` }}
|
||||
/>
|
||||
<div className="relative z-10 flex items-center space-x-6">
|
||||
<div className="relative">
|
||||
<Image
|
||||
src={artistImageUrl}
|
||||
alt={artist.name}
|
||||
width={120}
|
||||
height={120}
|
||||
className="rounded-full shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-4xl font-bold text-white mb-2">{artist.name}</h1>
|
||||
<p className="text-white/80 mb-4">{artist.albumCount} albums</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handlePlayArtist}
|
||||
disabled={isPlayingArtist}
|
||||
className="bg-primary hover:bg-primary/70"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{isPlayingArtist ? 'Adding to Queue...' : 'Play Artist'}
|
||||
</Button>
|
||||
<Button onClick={handleStar} variant="secondary">
|
||||
<Heart className={isStarred ? 'text-red-500' : 'text-gray-500'} fill={isStarred ? 'red' : 'none'} />
|
||||
{isStarred ? 'Starred' : 'Star Artist'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,10 +10,13 @@ import { AlbumArtwork } from '@/app/components/album-artwork';
|
||||
import { ArtistIcon } from '@/app/components/artist-icon';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { getNavidromeAPI, Album } from '@/lib/navidrome';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { Shuffle } from 'lucide-react';
|
||||
import Loading from '@/app/components/loading';
|
||||
|
||||
export default function BrowsePage() {
|
||||
const { artists, isLoading: contextLoading } = useNavidrome();
|
||||
const { shuffleAllAlbums } = useAudioPlayer();
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [isLoadingAlbums, setIsLoadingAlbums] = useState(false);
|
||||
@@ -86,6 +89,7 @@ export default function BrowsePage() {
|
||||
<>
|
||||
<Tabs defaultValue="music" className="h-full flex flex-col space-y-6">
|
||||
<TabsContent value="music" className="border-none p-0 outline-none flex flex-col flex-grow">
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xl font-semibold tracking-tight">
|
||||
@@ -95,6 +99,10 @@ export default function BrowsePage() {
|
||||
the people who make the music
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={shuffleAllAlbums} className="flex items-center gap-2">
|
||||
<Shuffle className="w-4 h-4" />
|
||||
Shuffle All Albums
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<div className="relative flex-grow">
|
||||
|
||||
@@ -33,6 +33,9 @@ interface AudioPlayerContextProps {
|
||||
addArtistToQueue: (artistId: string) => Promise<void>;
|
||||
playPreviousTrack: () => void;
|
||||
isLoading: boolean;
|
||||
shuffle: boolean;
|
||||
toggleShuffle: () => void;
|
||||
shuffleAllAlbums: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AudioPlayerContext = createContext<AudioPlayerContextProps | undefined>(undefined);
|
||||
@@ -42,6 +45,7 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
const [queue, setQueue] = useState<Track[]>([]);
|
||||
const [playedTracks, setPlayedTracks] = useState<Track[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [shuffle, setShuffle] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const api = useMemo(() => getNavidromeAPI(), []);
|
||||
|
||||
@@ -128,11 +132,20 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
localStorage.removeItem('navidrome-current-track-time');
|
||||
|
||||
if (queue.length > 0) {
|
||||
const nextTrack = queue[0];
|
||||
setQueue((prevQueue) => prevQueue.slice(1));
|
||||
let nextTrack;
|
||||
if (shuffle) {
|
||||
// Pick a random track from the queue
|
||||
const randomIndex = Math.floor(Math.random() * queue.length);
|
||||
nextTrack = queue[randomIndex];
|
||||
setQueue((prevQueue) => prevQueue.filter((_, i) => i !== randomIndex));
|
||||
} else {
|
||||
// Pick the first track in order
|
||||
nextTrack = queue[0];
|
||||
setQueue((prevQueue) => prevQueue.slice(1));
|
||||
}
|
||||
playTrack(nextTrack, true); // Auto-play next track
|
||||
}
|
||||
}, [queue, playTrack]);
|
||||
}, [queue, playTrack, shuffle]);
|
||||
|
||||
const playPreviousTrack = useCallback(() => {
|
||||
// Clear saved timestamp when changing tracks
|
||||
@@ -276,6 +289,45 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
}
|
||||
}, [queue, playTrack]);
|
||||
|
||||
const toggleShuffle = useCallback(() => {
|
||||
setShuffle(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const shuffleAllAlbums = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const albums = await api.getAlbums('alphabeticalByName', 500, 0);
|
||||
let allTracks: Track[] = [];
|
||||
|
||||
// Concatenate all tracks from each album into a single array
|
||||
for (const album of albums) {
|
||||
const { songs } = await api.getAlbum(album.id);
|
||||
const tracks = songs.map(songToTrack);
|
||||
allTracks = allTracks.concat(tracks);
|
||||
}
|
||||
|
||||
// Shuffle the combined tracks array
|
||||
allTracks.sort(() => Math.random() - 0.5);
|
||||
|
||||
// Set the shuffled tracks as the new queue
|
||||
setQueue(allTracks);
|
||||
|
||||
toast({
|
||||
title: "Shuffle All Albums",
|
||||
description: `Shuffled ${allTracks.length} tracks from all albums`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to shuffle all albums:', error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to shuffle all albums",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [api, songToTrack, toast]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
currentTrack,
|
||||
playTrack,
|
||||
@@ -290,7 +342,10 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
isLoading,
|
||||
playAlbum,
|
||||
playAlbumFromTrack,
|
||||
skipToTrackInQueue
|
||||
skipToTrackInQueue,
|
||||
shuffle,
|
||||
toggleShuffle,
|
||||
shuffleAllAlbums
|
||||
}), [
|
||||
currentTrack,
|
||||
queue,
|
||||
@@ -305,7 +360,10 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
playPreviousTrack,
|
||||
playAlbum,
|
||||
playAlbumFromTrack,
|
||||
skipToTrackInQueue
|
||||
skipToTrackInQueue,
|
||||
shuffle,
|
||||
toggleShuffle,
|
||||
shuffleAllAlbums
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -33,7 +33,7 @@ interface FullScreenPlayerProps {
|
||||
}
|
||||
|
||||
export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onClose, onOpenQueue }) => {
|
||||
const { currentTrack, playPreviousTrack, playNextTrack } = useAudioPlayer();
|
||||
const { currentTrack, playPreviousTrack, playNextTrack, shuffle, toggleShuffle } = useAudioPlayer();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
@@ -351,6 +351,16 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-4 lg:gap-6 mb-4 lg:mb-6 flex-shrink-0">
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${
|
||||
shuffle ? 'text-primary' : 'text-gray-400'
|
||||
}`}
|
||||
title={shuffle ? 'Disable Shuffle' : 'Enable Shuffle'}
|
||||
>
|
||||
<FaShuffle className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={playPreviousTrack}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { getNavidromeAPI, Album, Artist, Song, Playlist } from '@/lib/navidrome';
|
||||
import { getNavidromeAPI, Album, Artist, Song, Playlist, AlbumInfo, ArtistInfo } from '@/lib/navidrome';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
interface NavidromeContextType {
|
||||
@@ -23,8 +23,11 @@ interface NavidromeContextType {
|
||||
|
||||
// Methods
|
||||
searchMusic: (query: string) => Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }>;
|
||||
search2: (query: string) => Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }>;
|
||||
getAlbum: (albumId: string) => Promise<{ album: Album; songs: Song[] }>;
|
||||
getArtist: (artistId: string) => Promise<{ artist: Artist; albums: Album[] }>;
|
||||
getArtistInfo2: (artistId: string) => Promise<ArtistInfo>;
|
||||
getAlbumInfo2: (albumId: string) => Promise<AlbumInfo>;
|
||||
getPlaylist: (playlistId: string) => Promise<{ playlist: Playlist; songs: Song[] }>;
|
||||
getAllSongs: () => Promise<Song[]>;
|
||||
refreshData: () => Promise<void>;
|
||||
@@ -123,6 +126,17 @@ export const NavidromeProvider: React.FC<NavidromeProviderProps> = ({ children }
|
||||
}
|
||||
};
|
||||
|
||||
const search2 = async (query: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
return await api.search2(query);
|
||||
} catch (err) {
|
||||
console.error('Search2 failed:', err);
|
||||
setError('Search failed');
|
||||
return { artists: [], albums: [], songs: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const getAlbum = async (albumId: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
@@ -145,6 +159,28 @@ export const NavidromeProvider: React.FC<NavidromeProviderProps> = ({ children }
|
||||
}
|
||||
};
|
||||
|
||||
const getArtistInfo2 = async (artistId: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
return await api.getArtistInfo2(artistId);
|
||||
} catch (err) {
|
||||
console.error('Failed to get artist info:', err);
|
||||
setError('Failed to get artist info');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const getAlbumInfo2 = async (albumId: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
return await api.getAlbumInfo2(albumId);
|
||||
} catch (err) {
|
||||
console.error('Failed to get album info:', err);
|
||||
setError('Failed to get album info');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlaylist = async (playlistId: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
@@ -276,8 +312,11 @@ export const NavidromeProvider: React.FC<NavidromeProviderProps> = ({ children }
|
||||
|
||||
// Methods
|
||||
searchMusic,
|
||||
search2,
|
||||
getAlbum,
|
||||
getArtist,
|
||||
getArtistInfo2,
|
||||
getAlbumInfo2,
|
||||
getPlaylist,
|
||||
getAllSongs,
|
||||
refreshData,
|
||||
|
||||
@@ -15,6 +15,7 @@ interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
const isRoot = usePathname() === "/";
|
||||
const isBrowse = usePathname() === "/browse";
|
||||
const isSearch = usePathname() === "/search";
|
||||
const isAlbums = usePathname() === "/library/albums";
|
||||
const isArtists = usePathname() === "/library/artists";
|
||||
const isQueue = usePathname() === "/queue";
|
||||
@@ -68,6 +69,24 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
||||
Browse
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/search">
|
||||
<Button variant={isSearch ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-4 w-4"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
Search
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/queue">
|
||||
<Button variant={isQueue ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||
<svg
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function MusicPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full px-4 py-6 lg:px-8">
|
||||
<div className="h-full px-4 py-6 lg:px-8 pb-24">
|
||||
<>
|
||||
<Tabs defaultValue="music" className="h-full space-y-6">
|
||||
<TabsContent value="music" className="border-none p-0 outline-none">
|
||||
|
||||
242
app/search/page.tsx
Normal file
242
app/search/page.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { AlbumArtwork } from '@/app/components/album-artwork';
|
||||
import { ArtistIcon } from '@/app/components/artist-icon';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { getNavidromeAPI, Artist, Album, Song } from '@/lib/navidrome';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { Search, Play, Plus } from 'lucide-react';
|
||||
|
||||
export default function SearchPage() {
|
||||
const { search2 } = useNavidrome();
|
||||
const { addToQueue, playTrack } = useAudioPlayer();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<{
|
||||
artists: Artist[];
|
||||
albums: Album[];
|
||||
songs: Song[];
|
||||
}>({ artists: [], albums: [], songs: [] });
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const api = getNavidromeAPI();
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (query.trim() === '') {
|
||||
setSearchResults({ artists: [], albums: [], songs: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSearching(true);
|
||||
const results = await search2(query);
|
||||
setSearchResults(results);
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
setSearchResults({ artists: [], albums: [], songs: [] });
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
handleSearch(searchQuery);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handlePlaySong = (song: Song) => {
|
||||
const track = {
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
url: api.getStreamUrl(song.id),
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId
|
||||
};
|
||||
|
||||
playTrack(track);
|
||||
};
|
||||
|
||||
const handleAddToQueue = (song: Song) => {
|
||||
const track = {
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
url: api.getStreamUrl(song.id),
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId
|
||||
};
|
||||
|
||||
addToQueue(track);
|
||||
};
|
||||
|
||||
const formatDuration = (duration: number): string => {
|
||||
const minutes = Math.floor(duration / 60);
|
||||
const seconds = duration % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full px-4 py-6 lg:px-8">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Search</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Search for artists, albums, and songs in your music library
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="space-y-4">
|
||||
<div className="relative max-w-lg">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
placeholder="What do you want to listen to?"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 text-lg h-12"
|
||||
/>
|
||||
</div>
|
||||
{isSearching && <div className="text-sm text-muted-foreground">Searching...</div>}
|
||||
</div>
|
||||
|
||||
{/* Search Results */}
|
||||
{searchQuery && (
|
||||
<div className="space-y-6">
|
||||
{searchResults.artists.length === 0 && searchResults.albums.length === 0 && searchResults.songs.length === 0 && !isSearching && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-lg">No results found for "{searchQuery}"</p>
|
||||
<p className="text-muted-foreground text-sm mt-2">Try different keywords or check your spelling</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Artists */}
|
||||
{searchResults.artists.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Artists</h2>
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex space-x-4 pb-4">
|
||||
{searchResults.artists.map((artist) => (
|
||||
<ArtistIcon key={artist.id} artist={artist} className="flex-shrink-0" />
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Albums */}
|
||||
{searchResults.albums.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Albums</h2>
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex space-x-4 pb-4">
|
||||
{searchResults.albums.map((album) => (
|
||||
<AlbumArtwork
|
||||
key={album.id}
|
||||
album={album}
|
||||
className="flex-shrink-0 w-48"
|
||||
aspectRatio="square"
|
||||
width={192}
|
||||
height={192}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Songs */}
|
||||
{searchResults.songs.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Songs</h2>
|
||||
<div className="space-y-2">
|
||||
{searchResults.songs.slice(0, 10).map((song, index) => (
|
||||
<div key={song.id} className="group flex items-center space-x-3 p-3 hover:bg-accent rounded-lg transition-colors">
|
||||
<div className="w-8 text-center text-sm text-muted-foreground">
|
||||
<span className="group-hover:hidden">{index + 1}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePlaySong(song)}
|
||||
className="hidden group-hover:flex h-8 w-8 p-0"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Song Cover */}
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : '/default-user.jpg'}
|
||||
alt={song.album}
|
||||
className="w-12 h-12 rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Song Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{song.title}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{song.artist} • {song.album}</p>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleAddToQueue(song)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{searchResults.songs.length > 10 && (
|
||||
<div className="text-center pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing first 10 of {searchResults.songs.length} songs
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!searchQuery && (
|
||||
<div className="text-center py-24">
|
||||
<Search className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">Search your music</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Find your favorite artists, albums, and songs
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,6 +89,26 @@ export interface RadioStation {
|
||||
homePageUrl?: string;
|
||||
}
|
||||
|
||||
export interface AlbumInfo {
|
||||
notes?: string;
|
||||
musicBrainzId?: string;
|
||||
lastFmUrl?: string;
|
||||
smallImageUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
largeImageUrl?: string;
|
||||
biography?: string;
|
||||
}
|
||||
|
||||
export interface ArtistInfo {
|
||||
biography?: string;
|
||||
musicBrainzId?: string;
|
||||
lastFmUrl?: string;
|
||||
smallImageUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
largeImageUrl?: string;
|
||||
similarArtist?: Artist[];
|
||||
}
|
||||
|
||||
class NavidromeAPI {
|
||||
private config: NavidromeConfig;
|
||||
private clientName = 'stillnavidrome';
|
||||
@@ -365,6 +385,67 @@ class NavidromeAPI {
|
||||
async deleteInternetRadioStation(id: string): Promise<void> {
|
||||
await this.makeRequest('deleteInternetRadioStation', { id });
|
||||
}
|
||||
|
||||
async getArtistInfo(artistId: string): Promise<{ artist: Artist; info: ArtistInfo }> {
|
||||
const response = await this.makeRequest('getArtistInfo2', { id: artistId });
|
||||
const artistData = response.artist as Artist;
|
||||
const artistInfo = response.info as ArtistInfo;
|
||||
return {
|
||||
artist: artistData,
|
||||
info: artistInfo
|
||||
};
|
||||
}
|
||||
|
||||
async getAlbumInfo(albumId: string): Promise<{ album: Album; info: AlbumInfo }> {
|
||||
const response = await this.makeRequest('getAlbumInfo2', { id: albumId });
|
||||
const albumData = response.album as Album;
|
||||
const albumInfo = response.info as AlbumInfo;
|
||||
return {
|
||||
album: albumData,
|
||||
info: albumInfo
|
||||
};
|
||||
}
|
||||
|
||||
async search2(query: string, artistCount = 20, albumCount = 20, songCount = 20): Promise<{
|
||||
artists: Artist[];
|
||||
albums: Album[];
|
||||
songs: Song[];
|
||||
}> {
|
||||
const response = await this.makeRequest('search2', {
|
||||
query,
|
||||
artistCount,
|
||||
albumCount,
|
||||
songCount
|
||||
});
|
||||
|
||||
const searchData = response.searchResult2 as {
|
||||
artist?: Artist[];
|
||||
album?: Album[];
|
||||
song?: Song[];
|
||||
};
|
||||
|
||||
return {
|
||||
artists: searchData?.artist || [],
|
||||
albums: searchData?.album || [],
|
||||
songs: searchData?.song || []
|
||||
};
|
||||
}
|
||||
|
||||
async getArtistInfo2(artistId: string, count = 20, includeNotPresent = false): Promise<ArtistInfo> {
|
||||
const response = await this.makeRequest('getArtistInfo2', {
|
||||
id: artistId,
|
||||
count,
|
||||
includeNotPresent: includeNotPresent.toString()
|
||||
});
|
||||
return response.artistInfo2 as ArtistInfo;
|
||||
}
|
||||
|
||||
async getAlbumInfo2(albumId: string): Promise<AlbumInfo> {
|
||||
const response = await this.makeRequest('getAlbumInfo2', {
|
||||
id: albumId
|
||||
});
|
||||
return response.albumInfo2 as AlbumInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance management
|
||||
|
||||
Reference in New Issue
Block a user