feat: Add keyboard shortcuts and queue management features

- Implement global keyboard shortcuts for playback controls, volume adjustments, and navigation.
- Introduce drag-and-drop functionality for queue reordering with visual feedback.
- Add context menus for tracks, albums, and artists with quick action options.
- Develop Spotlight Search feature with Last.fm integration for enhanced music discovery.
- Create GlobalSearchProvider for managing search state and keyboard shortcuts.
- Ensure accessibility and keyboard navigation support across all new features.
This commit is contained in:
2025-08-12 13:09:33 +00:00
committed by GitHub
parent d467796b31
commit 9e7cc703bd
15 changed files with 1733 additions and 130 deletions

View File

@@ -11,6 +11,8 @@ import { useToast } from '@/hooks/use-toast';
import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler';
import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
import { useIsMobile } from '@/hooks/use-mobile';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import { useGlobalSearch } from './GlobalSearchProvider';
import { DraggableMiniPlayer } from './DraggableMiniPlayer';
export const AudioPlayer: React.FC = () => {
@@ -799,6 +801,34 @@ export const AudioPlayer: React.FC = () => {
}
}
};
// Volume control functions for keyboard shortcuts
const handleVolumeUp = useCallback(() => {
setVolume(prevVolume => Math.min(1, prevVolume + 0.1));
}, []);
const handleVolumeDown = useCallback(() => {
setVolume(prevVolume => Math.max(0, prevVolume - 0.1));
}, []);
const handleToggleMute = useCallback(() => {
setVolume(prevVolume => prevVolume === 0 ? 1 : 0);
}, []);
const { openSpotlight } = useGlobalSearch();
// Set up keyboard shortcuts
useKeyboardShortcuts({
onPlayPause: togglePlayPause,
onNextTrack: playNextTrack,
onPreviousTrack: playPreviousTrack,
onVolumeUp: handleVolumeUp,
onVolumeDown: handleVolumeDown,
onToggleMute: handleToggleMute,
onSpotlightSearch: openSpotlight,
disabled: !currentTrack || isFullScreen // Disable if no track or in fullscreen (let FullScreenPlayer handle it)
});
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value);
setVolume(newVolume);

View File

@@ -33,12 +33,14 @@ interface AudioPlayerContextProps {
playTrack: (track: Track, autoPlay?: boolean) => void;
queue: Track[];
addToQueue: (track: Track) => void;
insertAtBeginningOfQueue: (track: Track) => void;
playNextTrack: () => void;
clearQueue: () => void;
addAlbumToQueue: (albumId: string) => Promise<void>;
playAlbum: (albumId: string) => Promise<void>;
playAlbumFromTrack: (albumId: string, startingSongId: string) => Promise<void>;
removeTrackFromQueue: (index: number) => void;
reorderQueue: (oldIndex: number, newIndex: number) => void;
skipToTrackInQueue: (index: number) => void;
addArtistToQueue: (artistId: string) => Promise<void>;
playPreviousTrack: () => void;
@@ -291,6 +293,10 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
});
}, [shuffle]);
const insertAtBeginningOfQueue = useCallback((track: Track) => {
setQueue((prevQueue) => [track, ...prevQueue]);
}, []);
const clearQueue = useCallback(() => {
setQueue([]);
}, []);
@@ -299,6 +305,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
setQueue((prevQueue) => prevQueue.filter((_, i) => i !== index));
}, []);
const reorderQueue = useCallback((oldIndex: number, newIndex: number) => {
setQueue((prevQueue) => {
const newQueue = [...prevQueue];
const [movedItem] = newQueue.splice(oldIndex, 1);
newQueue.splice(newIndex, 0, movedItem);
return newQueue;
});
}, []);
const playNextTrack = useCallback(() => {
// Clear saved timestamp when changing tracks
localStorage.removeItem('navidrome-current-track-time');
@@ -736,10 +751,12 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
playTrack,
queue,
addToQueue,
insertAtBeginningOfQueue,
playNextTrack,
clearQueue,
addAlbumToQueue,
removeTrackFromQueue,
reorderQueue,
addArtistToQueue,
playPreviousTrack,
isLoading,
@@ -835,10 +852,12 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
isLoading,
playTrack,
addToQueue,
insertAtBeginningOfQueue,
playNextTrack,
clearQueue,
addAlbumToQueue,
removeTrackFromQueue,
reorderQueue,
addArtistToQueue,
playPreviousTrack,
playAlbum,

View File

@@ -4,6 +4,7 @@ import { useRouter, usePathname } from 'next/navigation';
import { Home, Search, Disc, Users, Music, Heart, List, Settings } from 'lucide-react';
import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
import { useGlobalSearch } from './GlobalSearchProvider';
interface NavItem {
href: string;
@@ -21,9 +22,15 @@ const navigationItems: NavItem[] = [
export function BottomNavigation() {
const router = useRouter();
const pathname = usePathname();
const { openSpotlight } = useGlobalSearch();
const handleNavigation = (href: string) => {
router.push(href);
if (href === '/search') {
// Use spotlight search instead of navigating to search page
openSpotlight();
} else {
router.push(href);
}
};
const isActive = (href: string) => {

View File

@@ -0,0 +1,260 @@
'use client';
import React from 'react';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
Play,
Plus,
ListMusic,
Heart,
SkipForward,
UserIcon,
Disc3,
Star,
Share,
Info
} from 'lucide-react';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { Track } from '@/app/components/AudioPlayerContext';
interface TrackContextMenuProps {
children: React.ReactNode;
track: Track;
showPlayOptions?: boolean;
showQueueOptions?: boolean;
showFavoriteOption?: boolean;
showAlbumArtistOptions?: boolean;
}
export function TrackContextMenu({
children,
track,
showPlayOptions = true,
showQueueOptions = true,
showFavoriteOption = true,
showAlbumArtistOptions = true
}: TrackContextMenuProps) {
const {
playTrack,
addToQueue,
insertAtBeginningOfQueue,
toggleCurrentTrackStar,
currentTrack,
queue
} = useAudioPlayer();
const handlePlayTrack = () => {
playTrack(track, true);
};
const handleAddToQueue = () => {
addToQueue(track);
};
const handlePlayNext = () => {
// Add track to the beginning of the queue to play next
insertAtBeginningOfQueue(track);
};
const handleToggleFavorite = () => {
if (currentTrack?.id === track.id) {
toggleCurrentTrackStar();
}
// For non-current tracks, we'd need a separate function to toggle favorites
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
{showPlayOptions && (
<>
<ContextMenuItem onClick={handlePlayTrack} className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Play Now
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{showQueueOptions && (
<>
<ContextMenuItem onClick={handlePlayNext} className="cursor-pointer">
<SkipForward className="mr-2 h-4 w-4" />
Play Next
</ContextMenuItem>
<ContextMenuItem onClick={handleAddToQueue} className="cursor-pointer">
<Plus className="mr-2 h-4 w-4" />
Add to Queue
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{showFavoriteOption && (
<>
<ContextMenuItem onClick={handleToggleFavorite} className="cursor-pointer">
<Heart className={`mr-2 h-4 w-4 ${track.starred ? 'fill-current text-red-500' : ''}`} />
{track.starred ? 'Remove from Favorites' : 'Add to Favorites'}
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{showAlbumArtistOptions && (
<>
<ContextMenuItem className="cursor-pointer">
<Disc3 className="mr-2 h-4 w-4" />
Go to Album
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<UserIcon className="mr-2 h-4 w-4" />
Go to Artist
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
Track Info
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Share className="mr-2 h-4 w-4" />
Share
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
// Additional context menus for albums and artists
interface AlbumContextMenuProps {
children: React.ReactNode;
albumId: string;
albumName: string;
}
export function AlbumContextMenu({
children,
albumId,
albumName
}: AlbumContextMenuProps) {
const { playAlbum, addAlbumToQueue } = useAudioPlayer();
const handlePlayAlbum = () => {
playAlbum(albumId);
};
const handleAddAlbumToQueue = () => {
addAlbumToQueue(albumId);
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={handlePlayAlbum} className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Play Album
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleAddAlbumToQueue} className="cursor-pointer">
<Plus className="mr-2 h-4 w-4" />
Add Album to Queue
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<SkipForward className="mr-2 h-4 w-4" />
Play Album Next
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<Heart className="mr-2 h-4 w-4" />
Add to Favorites
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<UserIcon className="mr-2 h-4 w-4" />
Go to Artist
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
Album Info
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Share className="mr-2 h-4 w-4" />
Share Album
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
interface ArtistContextMenuProps {
children: React.ReactNode;
artistId: string;
artistName: string;
}
export function ArtistContextMenu({
children,
artistId,
artistName
}: ArtistContextMenuProps) {
const { playArtist, addArtistToQueue } = useAudioPlayer();
const handlePlayArtist = () => {
playArtist(artistId);
};
const handleAddArtistToQueue = () => {
addArtistToQueue(artistId);
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={handlePlayArtist} className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Play All Songs
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleAddArtistToQueue} className="cursor-pointer">
<Plus className="mr-2 h-4 w-4" />
Add All to Queue
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<SkipForward className="mr-2 h-4 w-4" />
Play All Next
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<Heart className="mr-2 h-4 w-4" />
Add to Favorites
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<Info className="mr-2 h-4 w-4" />
Artist Info
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<Share className="mr-2 h-4 w-4" />
Share Artist
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -9,6 +9,8 @@ import { Progress } from '@/components/ui/progress';
import { lrcLibClient } from '@/lib/lrclib';
import Link from 'next/link';
import { useIsMobile } from '@/hooks/use-mobile';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import { useGlobalSearch } from './GlobalSearchProvider';
import { AudioSettingsDialog } from './AudioSettingsDialog';
import {
FaPlay,
@@ -419,6 +421,54 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
} catch {}
};
// Volume control functions for keyboard shortcuts
const handleVolumeUp = () => {
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
if (!mainAudio) return;
const newVolume = Math.min(1, mainAudio.volume + 0.1);
mainAudio.volume = newVolume;
setVolume(newVolume);
try {
localStorage.setItem('navidrome-volume', newVolume.toString());
} catch {}
};
const handleVolumeDown = () => {
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
if (!mainAudio) return;
const newVolume = Math.max(0, mainAudio.volume - 0.1);
mainAudio.volume = newVolume;
setVolume(newVolume);
try {
localStorage.setItem('navidrome-volume', newVolume.toString());
} catch {}
};
const handleToggleMute = () => {
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
if (!mainAudio) return;
const newVolume = mainAudio.volume === 0 ? 1 : 0;
mainAudio.volume = newVolume;
setVolume(newVolume);
try {
localStorage.setItem('navidrome-volume', newVolume.toString());
} catch {}
};
const { openSpotlight } = useGlobalSearch();
// Set up keyboard shortcuts for fullscreen player
useKeyboardShortcuts({
onPlayPause: togglePlayPause,
onNextTrack: playNextTrack,
onPreviousTrack: playPreviousTrack,
onVolumeUp: handleVolumeUp,
onVolumeDown: handleVolumeDown,
onToggleMute: handleToggleMute,
onSpotlightSearch: openSpotlight,
disabled: !isOpen || !currentTrack // Only active when fullscreen is open
});
const handleLyricClick = (time: number) => {
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
if (!mainAudio) return;

View File

@@ -0,0 +1,46 @@
'use client';
import React, { createContext, useContext, useState, useCallback } from 'react';
import { SpotlightSearch } from './SpotlightSearch';
interface GlobalSearchContextProps {
isSpotlightOpen: boolean;
openSpotlight: () => void;
closeSpotlight: () => void;
}
const GlobalSearchContext = createContext<GlobalSearchContextProps | undefined>(undefined);
export function GlobalSearchProvider({ children }: { children: React.ReactNode }) {
const [isSpotlightOpen, setIsSpotlightOpen] = useState(false);
const openSpotlight = useCallback(() => {
setIsSpotlightOpen(true);
}, []);
const closeSpotlight = useCallback(() => {
setIsSpotlightOpen(false);
}, []);
return (
<GlobalSearchContext.Provider value={{
isSpotlightOpen,
openSpotlight,
closeSpotlight
}}>
{children}
<SpotlightSearch
isOpen={isSpotlightOpen}
onClose={closeSpotlight}
/>
</GlobalSearchContext.Provider>
);
}
export function useGlobalSearch() {
const context = useContext(GlobalSearchContext);
if (!context) {
throw new Error('useGlobalSearch must be used within a GlobalSearchProvider');
}
return context;
}

View File

@@ -14,6 +14,7 @@ import { useViewportThemeColor } from "@/hooks/use-viewport-theme-color";
import { LoginForm } from "./start-screen";
import Image from "next/image";
import PageTransition from "./PageTransition";
import { GlobalSearchProvider } from "./GlobalSearchProvider";
// ServiceWorkerRegistration component to handle registration
function ServiceWorkerRegistration() {
@@ -109,10 +110,12 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
<OfflineNavidromeProvider>
<NavidromeErrorBoundary>
<AudioPlayerProvider>
<Ihateserverside>
<PageTransition>{children}</PageTransition>
</Ihateserverside>
<WhatsNewPopup />
<GlobalSearchProvider>
<Ihateserverside>
<PageTransition>{children}</PageTransition>
</Ihateserverside>
<WhatsNewPopup />
</GlobalSearchProvider>
</AudioPlayerProvider>
</NavidromeErrorBoundary>
</OfflineNavidromeProvider>

View File

@@ -0,0 +1,653 @@
'use client';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import {
Search,
X,
Music,
Disc,
User,
Clock,
Heart,
Play,
Plus,
ExternalLink,
Info,
Star,
TrendingUp,
Users,
Calendar,
Globe
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { lastFmAPI } from '@/lib/lastfm-api';
import { Song, Album, Artist, getNavidromeAPI } from '@/lib/navidrome';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import Image from 'next/image';
interface SpotlightSearchProps {
isOpen: boolean;
onClose: () => void;
}
interface LastFmTrackInfo {
name: string;
artist: {
name: string;
url: string;
};
album?: {
title: string;
image?: string;
};
wiki?: {
summary: string;
content: string;
};
duration?: string;
playcount?: string;
listeners?: string;
tags?: Array<{
name: string;
url: string;
}>;
}
interface LastFmTag {
name: string;
url: string;
}
interface LastFmArtist {
name: string;
url: string;
image?: Array<{ '#text': string; size: string }>;
}
interface LastFmBio {
summary: string;
content: string;
}
interface LastFmStats {
listeners: string;
playcount: string;
}
interface LastFmArtistInfo {
name: string;
bio?: LastFmBio;
stats?: LastFmStats;
tags?: {
tag: LastFmTag[];
};
similar?: {
artist: LastFmArtist[];
};
image?: Array<{ '#text': string; size: string }>;
}
interface SearchResult {
type: 'track' | 'album' | 'artist';
id: string;
title: string;
subtitle: string;
image?: string;
data: Song | Album | Artist;
lastFmData?: LastFmArtistInfo;
}
export function SpotlightSearch({ isOpen, onClose }: SpotlightSearchProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [selectedResult, setSelectedResult] = useState<SearchResult | null>(null);
const [lastFmDetails, setLastFmDetails] = useState<LastFmArtistInfo | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const api = getNavidromeAPI();
const { search2 } = useNavidrome();
const { playTrack, addToQueue, insertAtBeginningOfQueue } = useAudioPlayer();
// Convert Song to Track with proper URL generation
const songToTrack = useCallback((song: Song) => {
if (!api) {
throw new Error('Navidrome API not configured');
}
return {
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, 512) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred,
replayGain: song.replayGain || 0
};
}, [api]);
// Focus input when opened
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
// Close on escape
useKeyboardShortcuts({
disabled: !isOpen
});
// Handle keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, results.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (results[selectedIndex]) {
handleResultSelect(results[selectedIndex]);
}
break;
case 'Escape':
e.preventDefault();
if (showDetails) {
setShowDetails(false);
setSelectedResult(null);
} else {
onClose();
}
break;
case 'Tab':
e.preventDefault();
if (results[selectedIndex]) {
handleShowDetails(results[selectedIndex]);
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, results, selectedIndex, showDetails, onClose]);
// Search function with debouncing
const performSearch = useCallback(async (searchQuery: string) => {
if (!searchQuery.trim()) {
setResults([]);
return;
}
setIsLoading(true);
try {
const searchResults = await search2(searchQuery);
const formattedResults: SearchResult[] = [];
// Add tracks
searchResults.songs?.forEach(song => {
formattedResults.push({
type: 'track',
id: song.id,
title: song.title,
subtitle: `${song.artist}${song.album}`,
image: song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 256) : undefined,
data: song
});
});
// Add albums
searchResults.albums?.forEach(album => {
formattedResults.push({
type: 'album',
id: album.id,
title: album.name,
subtitle: `${album.artist}${album.songCount} tracks`,
image: album.coverArt && api ? api.getCoverArtUrl(album.coverArt, 256) : undefined,
data: album
});
});
// Add artists
searchResults.artists?.forEach(artist => {
formattedResults.push({
type: 'artist',
id: artist.id,
title: artist.name,
subtitle: `${artist.albumCount} albums`,
image: artist.coverArt && api ? api.getCoverArtUrl(artist.coverArt, 256) : undefined,
data: artist
});
});
setResults(formattedResults);
setSelectedIndex(0);
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setIsLoading(false);
}
}, [search2]);
// Debounced search
useEffect(() => {
const timeoutId = setTimeout(() => {
performSearch(query);
}, 300);
return () => clearTimeout(timeoutId);
}, [query, performSearch]);
const handleResultSelect = (result: SearchResult) => {
switch (result.type) {
case 'track':
const songData = result.data as Song;
const track = songToTrack(songData);
playTrack(track, true);
onClose();
break;
case 'album':
router.push(`/album/${result.id}`);
onClose();
break;
case 'artist':
router.push(`/artist/${result.id}`);
onClose();
break;
}
};
const handleShowDetails = async (result: SearchResult) => {
setSelectedResult(result);
setShowDetails(true);
setLastFmDetails(null);
// Fetch Last.fm data
try {
let lastFmData = null;
if (result.type === 'artist') {
const artistData = result.data as Artist;
lastFmData = await lastFmAPI.getArtistInfo(artistData.name);
} else if (result.type === 'album') {
// For albums, get artist info as Last.fm album info is limited
const albumData = result.data as Album;
lastFmData = await lastFmAPI.getArtistInfo(albumData.artist);
} else if (result.type === 'track') {
// For tracks, get artist info
const songData = result.data as Song;
lastFmData = await lastFmAPI.getArtistInfo(songData.artist);
}
setLastFmDetails(lastFmData);
} catch (error) {
console.error('Failed to fetch Last.fm data:', error);
}
};
const handlePlayNext = (result: SearchResult) => {
if (result.type === 'track') {
const songData = result.data as Song;
const track = songToTrack(songData);
insertAtBeginningOfQueue(track);
}
};
const handleAddToQueue = (result: SearchResult) => {
if (result.type === 'track') {
const songData = result.data as Song;
const track = songToTrack(songData);
addToQueue(track);
}
};
const getResultIcon = (type: string) => {
switch (type) {
case 'track': return <Music className="w-4 h-4" />;
case 'album': return <Disc className="w-4 h-4" />;
case 'artist': return <User className="w-4 h-4" />;
default: return <Search className="w-4 h-4" />;
}
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
onClick={onClose}
>
<div className="flex items-start justify-center pt-[10vh] px-4">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -20 }}
transition={{ type: "spring", duration: 0.4 }}
className="w-full max-w-2xl bg-background border border-border rounded-lg shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Search Input */}
<div className="flex items-center px-4 py-3 border-b border-border">
<Search className="w-5 h-5 text-muted-foreground mr-3" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search tracks, albums, artists..."
className="border-0 focus-visible:ring-0 text-lg bg-transparent"
/>
{query && (
<Button
variant="ghost"
size="sm"
onClick={() => setQuery('')}
className="p-1 h-auto"
>
<X className="w-4 h-4" />
</Button>
)}
</div>
{/* Results */}
<div className="max-h-96 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span className="ml-2 text-muted-foreground">Searching...</span>
</div>
) : results.length > 0 ? (
<ScrollArea className="max-h-96" ref={resultsRef}>
<div className="py-2">
{results.map((result, index) => (
<div
key={`${result.type}-${result.id}`}
className={`flex items-center px-4 py-3 cursor-pointer transition-colors ${
index === selectedIndex ? 'bg-accent' : 'hover:bg-accent/50'
}`}
onClick={() => handleResultSelect(result)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="flex items-center space-x-3 flex-1 min-w-0">
{result.image ? (
<Image
src={result.image}
alt={result.title}
width={40}
height={40}
className="w-10 h-10 rounded object-cover"
/>
) : (
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
{getResultIcon(result.type)}
</div>
)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{result.title}</div>
<div className="text-sm text-muted-foreground truncate">
{result.subtitle}
</div>
</div>
<Badge variant="secondary" className="capitalize">
{result.type}
</Badge>
</div>
{/* Quick Actions */}
<div className="flex items-center space-x-1 ml-3">
{result.type === 'track' && (
<>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handlePlayNext(result);
}}
className="h-8 w-8 p-0"
title="Play Next"
>
<Play className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleAddToQueue(result);
}}
className="h-8 w-8 p-0"
title="Add to Queue"
>
<Plus className="w-3 h-3" />
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleShowDetails(result);
}}
className="h-8 w-8 p-0"
title="Show Details (Tab)"
>
<Info className="w-3 h-3" />
</Button>
</div>
</div>
))}
</div>
</ScrollArea>
) : query.trim() ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Search className="w-8 h-8 mb-2" />
<p>No results found for &ldquo;{query}&rdquo;</p>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Music className="w-8 h-8 mb-2" />
<p>Start typing to search your music library</p>
<div className="text-xs mt-2 space-y-1">
<p> Use to navigate Enter to select</p>
<p> Tab for details Esc to close</p>
</div>
</div>
)}
</div>
</motion.div>
</div>
{/* Details Panel */}
<AnimatePresence>
{showDetails && selectedResult && (
<motion.div
initial={{ opacity: 0, x: 400 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 400 }}
transition={{ type: "spring", duration: 0.4 }}
className="fixed right-4 top-[10vh] bottom-4 w-80 bg-background border border-border rounded-lg shadow-2xl overflow-hidden"
>
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="font-semibold">Details</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDetails(false)}
className="h-8 w-8 p-0"
>
<X className="w-4 h-4" />
</Button>
</div>
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
{/* Basic Info */}
<div className="space-y-3">
{selectedResult.image && (
<Image
src={selectedResult.image}
alt={selectedResult.title}
width={200}
height={200}
className="w-full aspect-square rounded object-cover"
/>
)}
<div>
<h4 className="font-semibold text-lg">{selectedResult.title}</h4>
<p className="text-muted-foreground">{selectedResult.subtitle}</p>
<Badge variant="secondary" className="mt-1 capitalize">
{selectedResult.type}
</Badge>
</div>
</div>
{/* Last.fm Data */}
{lastFmDetails && (
<>
<Separator />
<div className="space-y-3">
<div className="flex items-center space-x-2">
<ExternalLink className="w-4 h-4" />
<span className="font-medium">Last.fm Info</span>
</div>
{/* Stats */}
{lastFmDetails.stats && (
<div className="grid grid-cols-2 gap-3">
<div className="text-center p-2 bg-muted rounded">
<div className="flex items-center justify-center space-x-1 mb-1">
<Users className="w-3 h-3" />
<span className="text-xs font-medium">Listeners</span>
</div>
<div className="text-sm font-semibold">
{parseInt(lastFmDetails.stats.listeners).toLocaleString()}
</div>
</div>
<div className="text-center p-2 bg-muted rounded">
<div className="flex items-center justify-center space-x-1 mb-1">
<TrendingUp className="w-3 h-3" />
<span className="text-xs font-medium">Plays</span>
</div>
<div className="text-sm font-semibold">
{parseInt(lastFmDetails.stats.playcount).toLocaleString()}
</div>
</div>
</div>
)}
{/* Bio */}
{lastFmDetails.bio?.summary && (
<div>
<h5 className="font-medium mb-2">Biography</h5>
<p className="text-sm text-muted-foreground leading-relaxed">
{lastFmDetails.bio.summary.replace(/<[^>]*>/g, '').split('\n')[0]}
</p>
</div>
)}
{/* Tags */}
{lastFmDetails.tags?.tag && (
<div>
<h5 className="font-medium mb-2">Tags</h5>
<div className="flex flex-wrap gap-1">
{lastFmDetails.tags.tag.slice(0, 6).map((tag: LastFmTag, index: number) => (
<Badge key={index} variant="outline" className="text-xs">
{tag.name}
</Badge>
))}
</div>
</div>
)}
{/* Similar Artists */}
{lastFmDetails.similar?.artist && (
<div>
<h5 className="font-medium mb-2">Similar Artists</h5>
<div className="space-y-2">
{lastFmDetails.similar.artist.slice(0, 4).map((artist: LastFmArtist, index: number) => (
<div key={index} className="flex items-center space-x-2">
<div className="w-8 h-8 bg-muted rounded flex items-center justify-center">
<User className="w-3 h-3" />
</div>
<span className="text-sm">{artist.name}</span>
</div>
))}
</div>
</div>
)}
</div>
</>
)}
{/* Actions */}
<Separator />
<div className="space-y-2">
<Button
onClick={() => handleResultSelect(selectedResult)}
className="w-full"
>
<Play className="w-4 h-4 mr-2" />
{selectedResult.type === 'track' ? 'Play Track' :
selectedResult.type === 'album' ? 'View Album' : 'View Artist'}
</Button>
{selectedResult.type === 'track' && (
<>
<Button
variant="outline"
onClick={() => handlePlayNext(selectedResult)}
className="w-full"
>
<Play className="w-4 h-4 mr-2" />
Play Next
</Button>
<Button
variant="outline"
onClick={() => handleAddToQueue(selectedResult)}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
Add to Queue
</Button>
</>
)}
</div>
</div>
</ScrollArea>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation';
import Image from "next/image";
import { Github, Mail, Menu as MenuIcon, X } from "lucide-react"
import { UserProfile } from "@/app/components/UserProfile";
import { useGlobalSearch } from "./GlobalSearchProvider";
import {
Menubar,
MenubarCheckboxItem,
@@ -75,6 +76,7 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
const [isClient, setIsClient] = useState(false);
const [navidromeUrl, setNavidromeUrl] = useState<string | null>(null);
const isMobile = useIsMobile();
const { openSpotlight } = useGlobalSearch();
// Navigation items for mobile menu
const navigationItems = [
@@ -333,9 +335,19 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
</Menubar>
)}
{/* User Profile - Desktop only */}
{/* User Profile and Search - Desktop only */}
{!isMobile && (
<div className="ml-auto">
<div className="ml-auto flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={openSpotlight}
className="flex items-center space-x-2"
title="Search (/ or ⌘K)"
>
<Search className="w-4 h-4" />
<span className="hidden lg:inline">Search</span>
</Button>
<UserProfile variant="desktop" />
</div>
)}

View File

@@ -3,14 +3,151 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Play, X, Disc, Trash2, SkipForward } from 'lucide-react';
import { Play, X, Disc, Trash2, SkipForward, GripVertical } from 'lucide-react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import {
CSS,
} from '@dnd-kit/utilities';
interface SortableQueueItemProps {
track: Track;
index: number;
onPlay: () => void;
onRemove: () => void;
formatDuration: (seconds: number) => string;
}
function SortableQueueItem({ track, index, onPlay, onRemove, formatDuration }: SortableQueueItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: `${track.id}-${index}` });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors ${
isDragging ? 'bg-accent' : ''
}`}
onClick={onPlay}
>
{/* Drag Handle */}
<div
className="mr-3 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing"
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
</div>
{/* Album Art with Play Indicator */}
<div className="w-12 h-12 mr-4 shrink-0 relative">
<Image
src={track.coverArt || '/default-user.jpg'}
alt={track.album}
width={48}
height={48}
className="w-full h-full object-cover rounded-md"
/>
<div className="absolute inset-0 bg-black/50 rounded-md opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="w-5 h-5 text-white" />
</div>
</div>
{/* Song Info */}
<div className="flex-1 min-w-0 mr-4">
<div className="flex items-center gap-2 mb-1">
<p className="font-semibold truncate">{track.name}</p>
</div>
<div className="flex items-center text-sm text-muted-foreground space-x-4">
<div className="flex items-center gap-1">
<Link
href={`/artist/${track.artistId}`}
className="truncate hover:text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{track.artist}
</Link>
</div>
</div>
</div>
{/* Duration */}
<div className="flex items-center text-sm text-muted-foreground mr-4">
{formatDuration(track.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={(e) => {
e.stopPropagation();
onRemove();
}}
className="h-8 w-8 p-0 hover:bg-destructive hover:text-destructive-foreground"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
);
}
const QueuePage: React.FC = () => {
const { queue, currentTrack, removeTrackFromQueue, clearQueue, skipToTrackInQueue } = useAudioPlayer();
const { queue, currentTrack, removeTrackFromQueue, clearQueue, skipToTrackInQueue, reorderQueue } = useAudioPlayer();
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = queue.findIndex((track, index) => `${track.id}-${index}` === active.id);
const newIndex = queue.findIndex((track, index) => `${track.id}-${index}` === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderQueue(oldIndex, newIndex);
}
}
};
const formatDuration = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
@@ -107,67 +244,29 @@ const QueuePage: React.FC = () => {
</p>
</div>
) : (
<div className="space-y-1">
{queue.map((track, index) => (
<div
key={`${track.id}-${index}`}
className="group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => skipToTrackInQueue(index)}
>
{/* Album Art with Play Indicator */}
<div className="w-12 h-12 mr-4 shrink-0 relative">
<Image
src={track.coverArt || '/default-user.jpg'}
alt={track.album}
width={48}
height={48}
className="w-full h-full object-cover rounded-md"
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={queue.map((track, index) => `${track.id}-${index}`)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{queue.map((track, index) => (
<SortableQueueItem
key={`${track.id}-${index}`}
track={track}
index={index}
onPlay={() => skipToTrackInQueue(index)}
onRemove={() => removeTrackFromQueue(index)}
formatDuration={formatDuration}
/>
<div className="absolute inset-0 bg-black/50 rounded-md opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="w-5 h-5 text-white" />
</div>
</div>
{/* Song Info */}
<div className="flex-1 min-w-0 mr-4">
<div className="flex items-center gap-2 mb-1">
<p className="font-semibold truncate">{track.name}</p>
</div>
<div className="flex items-center text-sm text-muted-foreground space-x-4">
<div className="flex items-center gap-1">
<Link
href={`/artist/${track.artistId}`}
className="truncate hover:text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{track.artist}
</Link>
</div>
</div>
</div>
{/* Duration */}
<div className="flex items-center text-sm text-muted-foreground mr-4">
{formatDuration(track.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={(e) => {
e.stopPropagation();
removeTrackFromQueue(index);
}}
className="h-8 w-8 p-0 hover:bg-destructive hover:text-destructive-foreground"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
))}
</div>
</SortableContext>
</DndContext>
)}
</ScrollArea>
</div>

View File

@@ -11,6 +11,7 @@ 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 { TrackContextMenu, AlbumContextMenu, ArtistContextMenu } from '@/app/components/ContextMenus';
import { Search, Play, Plus } from 'lucide-react';
export default function SearchPage() {
@@ -51,6 +52,31 @@ export default function SearchPage() {
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
// Focus search input when component mounts (for keyboard shortcut navigation)
useEffect(() => {
const searchInput = document.querySelector('input[type="text"]') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
}, []);
const createTrackFromSong = (song: Song) => {
if (!api) return null;
return {
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,
starred: !!song.starred
};
};
const handlePlaySong = (song: Song) => {
if (!api) {
console.error('Navidrome API not available');
@@ -136,25 +162,29 @@ export default function SearchPage() {
)}
{/* Artists */}
{/* {searchResults.artists.length > 0 && (
{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) => (
<ArtistContextMenu
key={artist.id}
artistId={artist.id}
artistName={artist.name}
>
<ArtistIcon
key={artist.id}
artist={artist}
className="shrink-0 overflow-hidden"
size={190}
/>
</ArtistContextMenu>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)} */}
{/* broken for now */}
)}
{/* Albums */}
{searchResults.albums.length > 0 && (
@@ -163,14 +193,19 @@ export default function SearchPage() {
<ScrollArea className="w-full">
<div className="flex space-x-4 pb-4">
{searchResults.albums.map((album) => (
<AlbumArtwork
key={album.id}
album={album}
className="shrink-0 w-48"
aspectRatio="square"
width={192}
height={192}
/>
<AlbumContextMenu
key={album.id}
albumId={album.id}
albumName={album.name}
>
<AlbumArtwork
album={album}
className="shrink-0 w-48"
aspectRatio="square"
width={192}
height={192}
/>
</AlbumContextMenu>
))}
</div>
<ScrollBar orientation="horizontal" />
@@ -183,54 +218,62 @@ export default function SearchPage() {
<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="shrink-0"> <Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : '/default-user.jpg'}
alt={song.album}
width={48}
height={48}
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.slice(0, 10).map((song, index) => {
const track = createTrackFromSong(song);
if (!track) return null;
return (
<TrackContextMenu key={song.id} track={track}>
<div className="group flex items-center space-x-3 p-3 hover:bg-accent rounded-lg transition-colors cursor-pointer">
<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="shrink-0">
<Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : '/default-user.jpg'}
alt={song.album}
width={48}
height={48}
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>
</TrackContextMenu>
);
})}
{searchResults.songs.length > 10 && (
<div className="text-center pt-4">