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:
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_COMMIT_SHA=1f6ebf1
|
||||
NEXT_PUBLIC_COMMIT_SHA=9427a2a
|
||||
|
||||
121
KEYBOARD_SHORTCUTS.md
Normal file
121
KEYBOARD_SHORTCUTS.md
Normal 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
135
SPOTLIGHT_SEARCH.md
Normal 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!
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
260
app/components/ContextMenus.tsx
Normal file
260
app/components/ContextMenus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
46
app/components/GlobalSearchProvider.tsx
Normal file
46
app/components/GlobalSearchProvider.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
653
app/components/SpotlightSearch.tsx
Normal file
653
app/components/SpotlightSearch.tsx
Normal 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 “{query}”</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
<AlbumContextMenu
|
||||
key={album.id}
|
||||
album={album}
|
||||
className="shrink-0 w-48"
|
||||
aspectRatio="square"
|
||||
width={192}
|
||||
height={192}
|
||||
/>
|
||||
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>
|
||||
{searchResults.songs.slice(0, 10).map((song, index) => {
|
||||
const track = createTrackFromSong(song);
|
||||
if (!track) return null;
|
||||
|
||||
{/* 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>
|
||||
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 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>
|
||||
{/* 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>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDuration(song.duration)}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
{/* 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">
|
||||
|
||||
125
hooks/use-keyboard-shortcuts.ts
Normal file
125
hooks/use-keyboard-shortcuts.ts
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user