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

@@ -1 +1 @@
NEXT_PUBLIC_COMMIT_SHA=1f6ebf1 NEXT_PUBLIC_COMMIT_SHA=9427a2a

121
KEYBOARD_SHORTCUTS.md Normal file
View File

@@ -0,0 +1,121 @@
# Keyboard Shortcuts & Queue Management Features
This document outlines the new keyboard shortcuts, queue management, and context menu features added to the music player.
## Keyboard Shortcuts
The following keyboard shortcuts work globally throughout the application:
### Playback Controls
- **Space** - Play/Pause current track
- **→ (Right Arrow)** - Skip to next track
- **← (Left Arrow)** - Skip to previous track
### Volume Controls
- **↑ (Up Arrow)** - Increase volume by 10%
- **↓ (Down Arrow)** - Decrease volume by 10%
- **M** - Toggle mute/unmute
### Navigation
- **/** - Quick search (navigates to search page and focuses input)
### Notes
- Keyboard shortcuts are disabled when typing in input fields
- When in fullscreen player mode, shortcuts are handled by the fullscreen player
- Volume changes are saved to localStorage
## Queue Management
### Drag and Drop Queue Reordering
- **Drag Handle**: Hover over queue items to reveal the grip handle (⋮⋮)
- **Reorder**: Click and drag the handle to reorder tracks in the queue
- **Visual Feedback**: Dragged items become semi-transparent during drag
- **Keyboard Support**: Use Tab to focus items, then Space + Arrow keys to reorder
### Queue Features
- Real-time visual feedback during drag operations
- Maintains playback order after reordering
- Works with both mouse and keyboard navigation
- Accessible drag and drop implementation
## Context Menus (Right-Click)
Right-click on tracks, albums, and artists to access quick actions:
### Track Context Menu
- **Play Now** - Immediately play the selected track
- **Play Next** - Add track to the beginning of the queue
- **Add to Queue** - Add track to the end of the queue
- **Add/Remove from Favorites** - Toggle favorite status
- **Go to Album** - Navigate to the track's album
- **Go to Artist** - Navigate to the track's artist
- **Track Info** - View detailed track information
- **Share** - Share the track
### Album Context Menu
- **Play Album** - Play the entire album from the beginning
- **Add Album to Queue** - Add all album tracks to queue
- **Play Album Next** - Add album tracks to beginning of queue
- **Add to Favorites** - Add album to favorites
- **Go to Artist** - Navigate to the album's artist
- **Album Info** - View detailed album information
- **Share Album** - Share the album
### Artist Context Menu
- **Play All Songs** - Play all songs by the artist
- **Add All to Queue** - Add all artist songs to queue
- **Play All Next** - Add all artist songs to beginning of queue
- **Add to Favorites** - Add artist to favorites
- **Artist Info** - View detailed artist information
- **Share Artist** - Share the artist
## Where to Find These Features
### Keyboard Shortcuts
- Available globally throughout the application
- Work in main player, fullscreen player, and all pages
- Search shortcut (/) works from any page
### Queue Management
- **Queue Page**: `/queue` - Full drag and drop interface
- **Mini Player**: Shows current track and basic controls
- **Fullscreen Player**: Queue management button available
### Context Menus
- **Search Results**: Right-click on any track, album, or artist
- **Album Pages**: Right-click on individual tracks
- **Artist Pages**: Right-click on tracks and albums
- **Queue Page**: Right-click on queued tracks
- **Library Browse**: Right-click on any item
## Technical Implementation
### Components Used
- `useKeyboardShortcuts` hook for global keyboard shortcuts
- `@dnd-kit` for drag and drop functionality
- `@radix-ui/react-context-menu` for context menus
- Custom context menu components for different content types
### Accessibility
- Full keyboard navigation support
- Screen reader compatible
- Focus management
- ARIA labels and descriptions
- High contrast support
## Tips for Users
1. **Keyboard Shortcuts**: Most shortcuts work anywhere in the app, just start typing
2. **Queue Reordering**: Hover over queue items to see the drag handle
3. **Context Menus**: Right-click almost anything to see available actions
4. **Quick Search**: Press `/` from anywhere to jump to search
5. **Volume Control**: Use arrow keys for precise volume adjustment
## Future Enhancements
Potential future additions:
- Custom keyboard shortcut configuration
- More queue management options (clear queue, save as playlist)
- Additional context menu actions (edit metadata, download)
- Gesture support for mobile devices
- Queue templates and smart playlists

135
SPOTLIGHT_SEARCH.md Normal file
View File

@@ -0,0 +1,135 @@
# Spotlight Search Feature
## Overview
The Spotlight Search feature provides a macOS Spotlight-style search interface for your music library, enhanced with Last.fm metadata for rich music information.
## Features
### 🔍 **Instant Search**
- **Global Search**: Press `Cmd+K` (macOS) / `Ctrl+K` (Windows/Linux) from anywhere in the app
- **Real-time Results**: Search as you type with 300ms debouncing
- **Multiple Types**: Search across tracks, albums, and artists simultaneously
### ⌨️ **Keyboard Navigation**
- `↑`/`↓` arrows to navigate results
- `Enter` to select and play/view
- `Tab` to show detailed information
- `Esc` to close (or close details panel)
### 🎵 **Quick Actions**
- **Play Now**: Click on any result to play immediately
- **Play Next**: Add track to the beginning of queue
- **Add to Queue**: Add track to the end of queue
- **Show Details**: Get rich information from Last.fm
### 🌍 **Last.fm Integration**
When viewing details, you'll see:
- **Artist Biography**: Rich biographical information
- **Statistics**: Play counts and listener numbers
- **Tags**: Genre and style tags
- **Similar Artists**: Discover new music based on your selections
- **Album Art**: High-quality images
## Usage
### Opening Search
- **Keyboard**: Press `Cmd+K` (macOS) / `Ctrl+K` (Windows/Linux)
- **Mouse**: Click the search button in the top menu bar (desktop)
- **Mobile**: Tap the search icon in the bottom navigation
### Search Tips
- Type partial song names, artist names, or album titles
- Results appear in real-time as you type
- Use keyboard navigation for fastest access
- Press Tab to see detailed Last.fm information
### Quick Actions
- **Tracks**: Play, Play Next, Add to Queue
- **Albums**: View album page, Add entire album to queue
- **Artists**: View artist page, Play all songs
## Last.fm Data
The search integrates with Last.fm to provide:
### Artist Information
- **Bio**: Artist biography and background
- **Stats**: Total plays and listeners globally
- **Similar**: Artists with similar style
- **Tags**: Genre classification and style tags
### Enhanced Discovery
- Click on similar artists to search for them
- Explore tags to discover new genres
- View play statistics to understand popularity
## Keyboard Shortcuts Summary
| Shortcut | Action |
|----------|--------|
| `Cmd+K` / `Ctrl+K` | Open Spotlight Search |
| `↑` / `↓` | Navigate results |
| `Enter` | Select result |
| `Tab` | Show details |
| `Esc` | Close search/details |
| `Space` | Play/Pause (when not in search) |
| `←` / `→` | Previous/Next track |
| `↑` / `↓` | Volume up/down (when not in search) |
| `M` | Toggle mute |
## Implementation Details
### Architecture
- **Global Context**: `GlobalSearchProvider` manages search state
- **Component**: `SpotlightSearch` handles UI and interactions
- **Hooks**: `useKeyboardShortcuts` for global hotkeys
- **Integration**: Uses existing Navidrome search API + Last.fm API
### Performance
- **Debounced Search**: 300ms delay prevents excessive API calls
- **Keyboard Optimized**: All interactions available via keyboard
- **Lazy Loading**: Last.fm data loaded only when details are viewed
- **Caching**: Search results cached during session
### Accessibility
- **Keyboard Navigation**: Full keyboard support
- **Screen Reader**: Proper ARIA labels and descriptions
- **Focus Management**: Automatic focus on search input
- **Visual Feedback**: Clear hover and selection states
## Future Enhancements
### Planned Features
- **Search History**: Remember recent searches
- **Smart Suggestions**: AI-powered search suggestions
- **Scoped Search**: Filter by type (tracks only, albums only, etc.)
- **Advanced Filters**: Date ranges, genres, etc.
- **Playlist Integration**: Search within specific playlists
### Last.fm Enhancements
- **Track Information**: Individual track details from Last.fm
- **Album Reviews**: User reviews and ratings
- **Concert Information**: Upcoming shows and tour dates
- **Scrobbling Integration**: Enhanced scrobbling with search data
## Troubleshooting
### Search Not Working
1. Check Navidrome connection in settings
2. Verify network connectivity
3. Try refreshing the page
### Last.fm Data Missing
1. Last.fm API may be unavailable
2. Artist/album may not exist in Last.fm database
3. Network connectivity issues
### Keyboard Shortcuts Not Working
1. Ensure you're not in an input field
2. Check if fullscreen mode is interfering
3. Try clicking outside any input fields first
The Spotlight Search feature transforms how you discover and interact with your music library, making it faster and more intuitive than ever before!

View File

@@ -11,6 +11,8 @@ import { useToast } from '@/hooks/use-toast';
import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler'; import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler';
import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm'; import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import { useGlobalSearch } from './GlobalSearchProvider';
import { DraggableMiniPlayer } from './DraggableMiniPlayer'; import { DraggableMiniPlayer } from './DraggableMiniPlayer';
export const AudioPlayer: React.FC = () => { 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 handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value); const newVolume = parseFloat(e.target.value);
setVolume(newVolume); setVolume(newVolume);

View File

@@ -33,12 +33,14 @@ interface AudioPlayerContextProps {
playTrack: (track: Track, autoPlay?: boolean) => void; playTrack: (track: Track, autoPlay?: boolean) => void;
queue: Track[]; queue: Track[];
addToQueue: (track: Track) => void; addToQueue: (track: Track) => void;
insertAtBeginningOfQueue: (track: Track) => void;
playNextTrack: () => void; playNextTrack: () => void;
clearQueue: () => void; clearQueue: () => void;
addAlbumToQueue: (albumId: string) => Promise<void>; addAlbumToQueue: (albumId: string) => Promise<void>;
playAlbum: (albumId: string) => Promise<void>; playAlbum: (albumId: string) => Promise<void>;
playAlbumFromTrack: (albumId: string, startingSongId: string) => Promise<void>; playAlbumFromTrack: (albumId: string, startingSongId: string) => Promise<void>;
removeTrackFromQueue: (index: number) => void; removeTrackFromQueue: (index: number) => void;
reorderQueue: (oldIndex: number, newIndex: number) => void;
skipToTrackInQueue: (index: number) => void; skipToTrackInQueue: (index: number) => void;
addArtistToQueue: (artistId: string) => Promise<void>; addArtistToQueue: (artistId: string) => Promise<void>;
playPreviousTrack: () => void; playPreviousTrack: () => void;
@@ -291,6 +293,10 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}); });
}, [shuffle]); }, [shuffle]);
const insertAtBeginningOfQueue = useCallback((track: Track) => {
setQueue((prevQueue) => [track, ...prevQueue]);
}, []);
const clearQueue = useCallback(() => { const clearQueue = useCallback(() => {
setQueue([]); setQueue([]);
}, []); }, []);
@@ -299,6 +305,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
setQueue((prevQueue) => prevQueue.filter((_, i) => i !== index)); 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(() => { const playNextTrack = useCallback(() => {
// Clear saved timestamp when changing tracks // Clear saved timestamp when changing tracks
localStorage.removeItem('navidrome-current-track-time'); localStorage.removeItem('navidrome-current-track-time');
@@ -736,10 +751,12 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
playTrack, playTrack,
queue, queue,
addToQueue, addToQueue,
insertAtBeginningOfQueue,
playNextTrack, playNextTrack,
clearQueue, clearQueue,
addAlbumToQueue, addAlbumToQueue,
removeTrackFromQueue, removeTrackFromQueue,
reorderQueue,
addArtistToQueue, addArtistToQueue,
playPreviousTrack, playPreviousTrack,
isLoading, isLoading,
@@ -835,10 +852,12 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
isLoading, isLoading,
playTrack, playTrack,
addToQueue, addToQueue,
insertAtBeginningOfQueue,
playNextTrack, playNextTrack,
clearQueue, clearQueue,
addAlbumToQueue, addAlbumToQueue,
removeTrackFromQueue, removeTrackFromQueue,
reorderQueue,
addArtistToQueue, addArtistToQueue,
playPreviousTrack, playPreviousTrack,
playAlbum, 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 { Home, Search, Disc, Users, Music, Heart, List, Settings } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useGlobalSearch } from './GlobalSearchProvider';
interface NavItem { interface NavItem {
href: string; href: string;
@@ -21,9 +22,15 @@ const navigationItems: NavItem[] = [
export function BottomNavigation() { export function BottomNavigation() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { openSpotlight } = useGlobalSearch();
const handleNavigation = (href: string) => { 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) => { 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 { lrcLibClient } from '@/lib/lrclib';
import Link from 'next/link'; import Link from 'next/link';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import { useGlobalSearch } from './GlobalSearchProvider';
import { AudioSettingsDialog } from './AudioSettingsDialog'; import { AudioSettingsDialog } from './AudioSettingsDialog';
import { import {
FaPlay, FaPlay,
@@ -419,6 +421,54 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
} catch {} } 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 handleLyricClick = (time: number) => {
const mainAudio = document.querySelector('audio') as HTMLAudioElement; const mainAudio = document.querySelector('audio') as HTMLAudioElement;
if (!mainAudio) return; 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 { LoginForm } from "./start-screen";
import Image from "next/image"; import Image from "next/image";
import PageTransition from "./PageTransition"; import PageTransition from "./PageTransition";
import { GlobalSearchProvider } from "./GlobalSearchProvider";
// ServiceWorkerRegistration component to handle registration // ServiceWorkerRegistration component to handle registration
function ServiceWorkerRegistration() { function ServiceWorkerRegistration() {
@@ -109,10 +110,12 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
<OfflineNavidromeProvider> <OfflineNavidromeProvider>
<NavidromeErrorBoundary> <NavidromeErrorBoundary>
<AudioPlayerProvider> <AudioPlayerProvider>
<Ihateserverside> <GlobalSearchProvider>
<PageTransition>{children}</PageTransition> <Ihateserverside>
</Ihateserverside> <PageTransition>{children}</PageTransition>
<WhatsNewPopup /> </Ihateserverside>
<WhatsNewPopup />
</GlobalSearchProvider>
</AudioPlayerProvider> </AudioPlayerProvider>
</NavidromeErrorBoundary> </NavidromeErrorBoundary>
</OfflineNavidromeProvider> </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 Image from "next/image";
import { Github, Mail, Menu as MenuIcon, X } from "lucide-react" import { Github, Mail, Menu as MenuIcon, X } from "lucide-react"
import { UserProfile } from "@/app/components/UserProfile"; import { UserProfile } from "@/app/components/UserProfile";
import { useGlobalSearch } from "./GlobalSearchProvider";
import { import {
Menubar, Menubar,
MenubarCheckboxItem, MenubarCheckboxItem,
@@ -75,6 +76,7 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
const [isClient, setIsClient] = useState(false); const [isClient, setIsClient] = useState(false);
const [navidromeUrl, setNavidromeUrl] = useState<string | null>(null); const [navidromeUrl, setNavidromeUrl] = useState<string | null>(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { openSpotlight } = useGlobalSearch();
// Navigation items for mobile menu // Navigation items for mobile menu
const navigationItems = [ const navigationItems = [
@@ -333,9 +335,19 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
</Menubar> </Menubar>
)} )}
{/* User Profile - Desktop only */} {/* User Profile and Search - Desktop only */}
{!isMobile && ( {!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" /> <UserProfile variant="desktop" />
</div> </div>
)} )}

View File

@@ -3,14 +3,151 @@
import React from 'react'; import React from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; 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 { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area'; 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 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 formatDuration = (seconds: number): string => {
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
@@ -107,67 +244,29 @@ const QueuePage: React.FC = () => {
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-1"> <DndContext
{queue.map((track, index) => ( sensors={sensors}
<div collisionDetection={closestCenter}
key={`${track.id}-${index}`} onDragEnd={handleDragEnd}
className="group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors" >
onClick={() => skipToTrackInQueue(index)} <SortableContext
> items={queue.map((track, index) => `${track.id}-${index}`)}
{/* Album Art with Play Indicator */} strategy={verticalListSortingStrategy}
<div className="w-12 h-12 mr-4 shrink-0 relative"> >
<Image <div className="space-y-1">
src={track.coverArt || '/default-user.jpg'} {queue.map((track, index) => (
alt={track.album} <SortableQueueItem
width={48} key={`${track.id}-${index}`}
height={48} track={track}
className="w-full h-full object-cover rounded-md" 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>
</div> </DndContext>
)} )}
</ScrollArea> </ScrollArea>
</div> </div>

View File

@@ -11,6 +11,7 @@ import { ArtistIcon } from '@/app/components/artist-icon';
import { useNavidrome } from '@/app/components/NavidromeContext'; import { useNavidrome } from '@/app/components/NavidromeContext';
import { getNavidromeAPI, Artist, Album, Song } from '@/lib/navidrome'; import { getNavidromeAPI, Artist, Album, Song } from '@/lib/navidrome';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { TrackContextMenu, AlbumContextMenu, ArtistContextMenu } from '@/app/components/ContextMenus';
import { Search, Play, Plus } from 'lucide-react'; import { Search, Play, Plus } from 'lucide-react';
export default function SearchPage() { export default function SearchPage() {
@@ -51,6 +52,31 @@ export default function SearchPage() {
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]); }, [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) => { const handlePlaySong = (song: Song) => {
if (!api) { if (!api) {
console.error('Navidrome API not available'); console.error('Navidrome API not available');
@@ -136,25 +162,29 @@ export default function SearchPage() {
)} )}
{/* Artists */} {/* Artists */}
{/* {searchResults.artists.length > 0 && ( {searchResults.artists.length > 0 && (
<div> <div>
<h2 className="text-2xl font-bold mb-4">Artists</h2> <h2 className="text-2xl font-bold mb-4">Artists</h2>
<ScrollArea className="w-full"> <ScrollArea className="w-full">
<div className="flex space-x-4 pb-4"> <div className="flex space-x-4 pb-4">
{searchResults.artists.map((artist) => ( {searchResults.artists.map((artist) => (
<ArtistContextMenu
key={artist.id}
artistId={artist.id}
artistName={artist.name}
>
<ArtistIcon <ArtistIcon
key={artist.id}
artist={artist} artist={artist}
className="shrink-0 overflow-hidden" className="shrink-0 overflow-hidden"
size={190} size={190}
/> />
</ArtistContextMenu>
))} ))}
</div> </div>
<ScrollBar orientation="horizontal" /> <ScrollBar orientation="horizontal" />
</ScrollArea> </ScrollArea>
</div> </div>
)} */} )}
{/* broken for now */}
{/* Albums */} {/* Albums */}
{searchResults.albums.length > 0 && ( {searchResults.albums.length > 0 && (
@@ -163,14 +193,19 @@ export default function SearchPage() {
<ScrollArea className="w-full"> <ScrollArea className="w-full">
<div className="flex space-x-4 pb-4"> <div className="flex space-x-4 pb-4">
{searchResults.albums.map((album) => ( {searchResults.albums.map((album) => (
<AlbumArtwork <AlbumContextMenu
key={album.id} key={album.id}
album={album} albumId={album.id}
className="shrink-0 w-48" albumName={album.name}
aspectRatio="square" >
width={192} <AlbumArtwork
height={192} album={album}
/> className="shrink-0 w-48"
aspectRatio="square"
width={192}
height={192}
/>
</AlbumContextMenu>
))} ))}
</div> </div>
<ScrollBar orientation="horizontal" /> <ScrollBar orientation="horizontal" />
@@ -183,54 +218,62 @@ export default function SearchPage() {
<div> <div>
<h2 className="text-2xl font-bold mb-4">Songs</h2> <h2 className="text-2xl font-bold mb-4">Songs</h2>
<div className="space-y-2"> <div className="space-y-2">
{searchResults.songs.slice(0, 10).map((song, index) => ( {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"> const track = createTrackFromSong(song);
<div className="w-8 text-center text-sm text-muted-foreground"> if (!track) return null;
<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 */} return (
<div className="shrink-0"> <Image <TrackContextMenu key={song.id} track={track}>
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : '/default-user.jpg'} <div className="group flex items-center space-x-3 p-3 hover:bg-accent rounded-lg transition-colors cursor-pointer">
alt={song.album} <div className="w-8 text-center text-sm text-muted-foreground">
width={48} <span className="group-hover:hidden">{index + 1}</span>
height={48} <Button
className="w-12 h-12 rounded-md object-cover" variant="ghost"
/> size="sm"
</div> onClick={() => handlePlaySong(song)}
className="hidden group-hover:flex h-8 w-8 p-0"
>
<Play className="w-4 h-4" />
</Button>
</div>
{/* Song Info */} {/* Song Cover */}
<div className="flex-1 min-w-0"> <div className="shrink-0">
<p className="font-medium truncate">{song.title}</p> <Image
<p className="text-sm text-muted-foreground truncate">{song.artist} {song.album}</p> src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : '/default-user.jpg'}
</div> alt={song.album}
width={48}
height={48}
className="w-12 h-12 rounded-md object-cover"
/>
</div>
{/* Duration */} {/* Song Info */}
<div className="text-sm text-muted-foreground"> <div className="flex-1 min-w-0">
{formatDuration(song.duration)} <p className="font-medium truncate">{song.title}</p>
</div> <p className="text-sm text-muted-foreground truncate">{song.artist} {song.album}</p>
</div>
{/* Actions */} {/* Duration */}
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="text-sm text-muted-foreground">
<Button {formatDuration(song.duration)}
variant="ghost" </div>
size="sm"
onClick={() => handleAddToQueue(song)} {/* Actions */}
className="h-8 w-8 p-0" <div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
> <Button
<Plus className="w-4 h-4" /> variant="ghost"
</Button> size="sm"
</div> onClick={() => handleAddToQueue(song)}
</div> className="h-8 w-8 p-0"
))} >
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
</TrackContextMenu>
);
})}
{searchResults.songs.length > 10 && ( {searchResults.songs.length > 10 && (
<div className="text-center pt-4"> <div className="text-center pt-4">

View File

@@ -0,0 +1,125 @@
'use client';
import { useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
interface KeyboardShortcutsOptions {
onPlayPause?: () => void;
onNextTrack?: () => void;
onPreviousTrack?: () => void;
onVolumeUp?: () => void;
onVolumeDown?: () => void;
onToggleMute?: () => void;
onSpotlightSearch?: () => void;
disabled?: boolean;
}
export function useKeyboardShortcuts({
onPlayPause,
onNextTrack,
onPreviousTrack,
onVolumeUp,
onVolumeDown,
onToggleMute,
onSpotlightSearch,
disabled = false
}: KeyboardShortcutsOptions = {}) {
const router = useRouter();
const handleKeyDown = useCallback((event: KeyboardEvent) => {
// Don't trigger shortcuts if user is typing in an input field
const target = event.target as HTMLElement;
const isInputField = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true' ||
target.closest('[data-cmdk-input]'); // Command palette input
if (disabled || isInputField) return;
// Prevent default behavior for our shortcuts
const preventDefault = () => {
event.preventDefault();
event.stopPropagation();
};
switch (event.key) {
case ' ': // Space - Play/Pause
if (onPlayPause) {
preventDefault();
onPlayPause();
}
break;
case 'ArrowRight': // Right Arrow - Next Track
if (onNextTrack) {
preventDefault();
onNextTrack();
}
break;
case 'ArrowLeft': // Left Arrow - Previous Track
if (onPreviousTrack) {
preventDefault();
onPreviousTrack();
}
break;
case 'ArrowUp': // Up Arrow - Volume Up
if (onVolumeUp) {
preventDefault();
onVolumeUp();
}
break;
case 'ArrowDown': // Down Arrow - Volume Down
if (onVolumeDown) {
preventDefault();
onVolumeDown();
}
break;
case 'm': // M - Toggle Mute
case 'M':
if (onToggleMute) {
preventDefault();
onToggleMute();
}
break;
case 'k': // Cmd+K or Ctrl+K - Spotlight Search
case 'K':
if ((event.metaKey || event.ctrlKey) && onSpotlightSearch) {
preventDefault();
onSpotlightSearch();
}
break;
default:
break;
}
}, [
disabled,
onPlayPause,
onNextTrack,
onPreviousTrack,
onVolumeUp,
onVolumeDown,
onToggleMute,
onSpotlightSearch,
router
]);
useEffect(() => {
if (typeof window === 'undefined') return;
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
return {
// Return any utility functions if needed
isShortcutActive: !disabled
};
}