feat: add search functionality to find artists, albums, and songs with improved UI and results display

This commit is contained in:
2025-06-20 00:14:04 +00:00
committed by GitHub
parent 98b348bb34
commit 6653420e31
9 changed files with 499 additions and 36 deletions

View File

@@ -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 (

View File

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

View File

@@ -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,

View File

@@ -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