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 { 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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
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 { 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;
|
||||||
|
|||||||
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 { 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>
|
||||||
|
|||||||
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 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
return (
|
||||||
variant="ghost"
|
<TrackContextMenu key={song.id} track={track}>
|
||||||
size="sm"
|
<div className="group flex items-center space-x-3 p-3 hover:bg-accent rounded-lg transition-colors cursor-pointer">
|
||||||
onClick={() => handlePlaySong(song)}
|
<div className="w-8 text-center text-sm text-muted-foreground">
|
||||||
className="hidden group-hover:flex h-8 w-8 p-0"
|
<span className="group-hover:hidden">{index + 1}</span>
|
||||||
>
|
<Button
|
||||||
<Play className="w-4 h-4" />
|
variant="ghost"
|
||||||
</Button>
|
size="sm"
|
||||||
</div>
|
onClick={() => handlePlaySong(song)}
|
||||||
|
className="hidden group-hover:flex h-8 w-8 p-0"
|
||||||
{/* Song Cover */}
|
>
|
||||||
<div className="shrink-0"> <Image
|
<Play className="w-4 h-4" />
|
||||||
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : '/default-user.jpg'}
|
</Button>
|
||||||
alt={song.album}
|
</div>
|
||||||
width={48}
|
|
||||||
height={48}
|
{/* Song Cover */}
|
||||||
className="w-12 h-12 rounded-md object-cover"
|
<div className="shrink-0">
|
||||||
/>
|
<Image
|
||||||
</div>
|
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : '/default-user.jpg'}
|
||||||
|
alt={song.album}
|
||||||
{/* Song Info */}
|
width={48}
|
||||||
<div className="flex-1 min-w-0">
|
height={48}
|
||||||
<p className="font-medium truncate">{song.title}</p>
|
className="w-12 h-12 rounded-md object-cover"
|
||||||
<p className="text-sm text-muted-foreground truncate">{song.artist} • {song.album}</p>
|
/>
|
||||||
</div>
|
</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 */}
|
|
||||||
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
{/* Duration */}
|
||||||
<Button
|
<div className="text-sm text-muted-foreground">
|
||||||
variant="ghost"
|
{formatDuration(song.duration)}
|
||||||
size="sm"
|
</div>
|
||||||
onClick={() => handleAddToQueue(song)}
|
|
||||||
className="h-8 w-8 p-0"
|
{/* Actions */}
|
||||||
>
|
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<Plus className="w-4 h-4" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</div>
|
size="sm"
|
||||||
</div>
|
onClick={() => handleAddToQueue(song)}
|
||||||
))}
|
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">
|
||||||
|
|||||||
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