From 9e7cc703bdd67287a961ab3de721db89d6972734 Mon Sep 17 00:00:00 2001 From: angel Date: Tue, 12 Aug 2025 13:09:33 +0000 Subject: [PATCH] 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. --- .env.local | 2 +- KEYBOARD_SHORTCUTS.md | 121 +++++ SPOTLIGHT_SEARCH.md | 135 +++++ app/components/AudioPlayer.tsx | 30 ++ app/components/AudioPlayerContext.tsx | 19 + app/components/BottomNavigation.tsx | 9 +- app/components/ContextMenus.tsx | 260 ++++++++++ app/components/FullScreenPlayer.tsx | 50 ++ app/components/GlobalSearchProvider.tsx | 46 ++ app/components/RootLayoutClient.tsx | 11 +- app/components/SpotlightSearch.tsx | 653 ++++++++++++++++++++++++ app/components/menu.tsx | 16 +- app/queue/page.tsx | 223 +++++--- app/search/page.tsx | 163 +++--- hooks/use-keyboard-shortcuts.ts | 125 +++++ 15 files changed, 1733 insertions(+), 130 deletions(-) create mode 100644 KEYBOARD_SHORTCUTS.md create mode 100644 SPOTLIGHT_SEARCH.md create mode 100644 app/components/ContextMenus.tsx create mode 100644 app/components/GlobalSearchProvider.tsx create mode 100644 app/components/SpotlightSearch.tsx create mode 100644 hooks/use-keyboard-shortcuts.ts diff --git a/.env.local b/.env.local index 43b94c4..033cab2 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=1f6ebf1 +NEXT_PUBLIC_COMMIT_SHA=9427a2a diff --git a/KEYBOARD_SHORTCUTS.md b/KEYBOARD_SHORTCUTS.md new file mode 100644 index 0000000..864e68b --- /dev/null +++ b/KEYBOARD_SHORTCUTS.md @@ -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 diff --git a/SPOTLIGHT_SEARCH.md b/SPOTLIGHT_SEARCH.md new file mode 100644 index 0000000..1dc6fd8 --- /dev/null +++ b/SPOTLIGHT_SEARCH.md @@ -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! diff --git a/app/components/AudioPlayer.tsx b/app/components/AudioPlayer.tsx index 2381b9b..75477e1 100644 --- a/app/components/AudioPlayer.tsx +++ b/app/components/AudioPlayer.tsx @@ -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) => { const newVolume = parseFloat(e.target.value); setVolume(newVolume); diff --git a/app/components/AudioPlayerContext.tsx b/app/components/AudioPlayerContext.tsx index 87bd456..32aa4ca 100644 --- a/app/components/AudioPlayerContext.tsx +++ b/app/components/AudioPlayerContext.tsx @@ -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; playAlbum: (albumId: string) => Promise; playAlbumFromTrack: (albumId: string, startingSongId: string) => Promise; removeTrackFromQueue: (index: number) => void; + reorderQueue: (oldIndex: number, newIndex: number) => void; skipToTrackInQueue: (index: number) => void; addArtistToQueue: (artistId: string) => Promise; 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, diff --git a/app/components/BottomNavigation.tsx b/app/components/BottomNavigation.tsx index b39f0fb..1a240c2 100644 --- a/app/components/BottomNavigation.tsx +++ b/app/components/BottomNavigation.tsx @@ -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) => { diff --git a/app/components/ContextMenus.tsx b/app/components/ContextMenus.tsx new file mode 100644 index 0000000..a145662 --- /dev/null +++ b/app/components/ContextMenus.tsx @@ -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 ( + + + {children} + + + {showPlayOptions && ( + <> + + + Play Now + + + + )} + + {showQueueOptions && ( + <> + + + Play Next + + + + Add to Queue + + + + )} + + {showFavoriteOption && ( + <> + + + {track.starred ? 'Remove from Favorites' : 'Add to Favorites'} + + + + )} + + {showAlbumArtistOptions && ( + <> + + + Go to Album + + + + Go to Artist + + + + )} + + + + Track Info + + + + Share + + + + ); +} + +// 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 ( + + + {children} + + + + + Play Album + + + + + Add Album to Queue + + + + Play Album Next + + + + + Add to Favorites + + + + + Go to Artist + + + + Album Info + + + + Share Album + + + + ); +} + +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 ( + + + {children} + + + + + Play All Songs + + + + + Add All to Queue + + + + Play All Next + + + + + Add to Favorites + + + + + Artist Info + + + + Share Artist + + + + ); +} diff --git a/app/components/FullScreenPlayer.tsx b/app/components/FullScreenPlayer.tsx index e4e31ff..89c56e4 100644 --- a/app/components/FullScreenPlayer.tsx +++ b/app/components/FullScreenPlayer.tsx @@ -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 = ({ 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; diff --git a/app/components/GlobalSearchProvider.tsx b/app/components/GlobalSearchProvider.tsx new file mode 100644 index 0000000..31aa341 --- /dev/null +++ b/app/components/GlobalSearchProvider.tsx @@ -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(undefined); + +export function GlobalSearchProvider({ children }: { children: React.ReactNode }) { + const [isSpotlightOpen, setIsSpotlightOpen] = useState(false); + + const openSpotlight = useCallback(() => { + setIsSpotlightOpen(true); + }, []); + + const closeSpotlight = useCallback(() => { + setIsSpotlightOpen(false); + }, []); + + return ( + + {children} + + + ); +} + +export function useGlobalSearch() { + const context = useContext(GlobalSearchContext); + if (!context) { + throw new Error('useGlobalSearch must be used within a GlobalSearchProvider'); + } + return context; +} diff --git a/app/components/RootLayoutClient.tsx b/app/components/RootLayoutClient.tsx index 7444607..c7b0f18 100644 --- a/app/components/RootLayoutClient.tsx +++ b/app/components/RootLayoutClient.tsx @@ -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 - - {children} - - + + + {children} + + + diff --git a/app/components/SpotlightSearch.tsx b/app/components/SpotlightSearch.tsx new file mode 100644 index 0000000..9f617e7 --- /dev/null +++ b/app/components/SpotlightSearch.tsx @@ -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([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [showDetails, setShowDetails] = useState(false); + const [selectedResult, setSelectedResult] = useState(null); + const [lastFmDetails, setLastFmDetails] = useState(null); + + const inputRef = useRef(null); + const resultsRef = useRef(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 ; + case 'album': return ; + case 'artist': return ; + default: return ; + } + }; + + if (!isOpen) return null; + + return ( + + +
+ e.stopPropagation()} + > + {/* Search Input */} +
+ + setQuery(e.target.value)} + placeholder="Search tracks, albums, artists..." + className="border-0 focus-visible:ring-0 text-lg bg-transparent" + /> + {query && ( + + )} +
+ + {/* Results */} +
+ {isLoading ? ( +
+
+ Searching... +
+ ) : results.length > 0 ? ( + +
+ {results.map((result, index) => ( +
handleResultSelect(result)} + onMouseEnter={() => setSelectedIndex(index)} + > +
+ {result.image ? ( + {result.title} + ) : ( +
+ {getResultIcon(result.type)} +
+ )} +
+
{result.title}
+
+ {result.subtitle} +
+
+ + {result.type} + +
+ + {/* Quick Actions */} +
+ {result.type === 'track' && ( + <> + + + + )} + +
+
+ ))} +
+
+ ) : query.trim() ? ( +
+ +

No results found for “{query}”

+
+ ) : ( +
+ +

Start typing to search your music library

+
+

• Use ↑↓ to navigate • Enter to select

+

• Tab for details • Esc to close

+
+
+ )} +
+
+
+ + {/* Details Panel */} + + {showDetails && selectedResult && ( + +
+

Details

+ +
+ + +
+ {/* Basic Info */} +
+ {selectedResult.image && ( + {selectedResult.title} + )} +
+

{selectedResult.title}

+

{selectedResult.subtitle}

+ + {selectedResult.type} + +
+
+ + {/* Last.fm Data */} + {lastFmDetails && ( + <> + +
+
+ + Last.fm Info +
+ + {/* Stats */} + {lastFmDetails.stats && ( +
+
+
+ + Listeners +
+
+ {parseInt(lastFmDetails.stats.listeners).toLocaleString()} +
+
+
+
+ + Plays +
+
+ {parseInt(lastFmDetails.stats.playcount).toLocaleString()} +
+
+
+ )} + + {/* Bio */} + {lastFmDetails.bio?.summary && ( +
+
Biography
+

+ {lastFmDetails.bio.summary.replace(/<[^>]*>/g, '').split('\n')[0]} +

+
+ )} + + {/* Tags */} + {lastFmDetails.tags?.tag && ( +
+
Tags
+
+ {lastFmDetails.tags.tag.slice(0, 6).map((tag: LastFmTag, index: number) => ( + + {tag.name} + + ))} +
+
+ )} + + {/* Similar Artists */} + {lastFmDetails.similar?.artist && ( +
+
Similar Artists
+
+ {lastFmDetails.similar.artist.slice(0, 4).map((artist: LastFmArtist, index: number) => ( +
+
+ +
+ {artist.name} +
+ ))} +
+
+ )} +
+ + )} + + {/* Actions */} + +
+ + {selectedResult.type === 'track' && ( + <> + + + + )} +
+
+
+
+ )} +
+
+
+ ); +} diff --git a/app/components/menu.tsx b/app/components/menu.tsx index 66f94bb..20ed9b0 100644 --- a/app/components/menu.tsx +++ b/app/components/menu.tsx @@ -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(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 )} - {/* User Profile - Desktop only */} + {/* User Profile and Search - Desktop only */} {!isMobile && ( -
+
+
)} diff --git a/app/queue/page.tsx b/app/queue/page.tsx index 831c9ca..0f8a56f 100644 --- a/app/queue/page.tsx +++ b/app/queue/page.tsx @@ -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 ( +
+ {/* Drag Handle */} +
e.stopPropagation()} + > + +
+ + {/* Album Art with Play Indicator */} +
+ {track.album} +
+ +
+
+ + {/* Song Info */} +
+
+

{track.name}

+
+
+
+ e.stopPropagation()} + > + {track.artist} + +
+
+
+ + {/* Duration */} +
+ {formatDuration(track.duration)} +
+ + {/* Actions */} +
+ +
+
+ ); +} 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 = () => {

) : ( -
- {queue.map((track, index) => ( -
skipToTrackInQueue(index)} - > - {/* Album Art with Play Indicator */} -
- {track.album} + `${track.id}-${index}`)} + strategy={verticalListSortingStrategy} + > +
+ {queue.map((track, index) => ( + skipToTrackInQueue(index)} + onRemove={() => removeTrackFromQueue(index)} + formatDuration={formatDuration} /> -
- -
-
- - {/* Song Info */} -
-
-

{track.name}

-
-
-
- e.stopPropagation()} - > - {track.artist} - -
-
-
- - {/* Duration */} -
- {formatDuration(track.duration)} -
- - {/* Actions */} -
- -
+ ))}
- ))} -
+ + )}
diff --git a/app/search/page.tsx b/app/search/page.tsx index c165cb5..29977b5 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -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 && (

Artists

{searchResults.artists.map((artist) => ( + + ))}
- )} */} - {/* broken for now */} + )} {/* Albums */} {searchResults.albums.length > 0 && ( @@ -163,14 +193,19 @@ export default function SearchPage() {
{searchResults.albums.map((album) => ( - + + + ))}
@@ -183,54 +218,62 @@ export default function SearchPage() {

Songs

- {searchResults.songs.slice(0, 10).map((song, index) => ( -
-
- {index + 1} - -
- - {/* Song Cover */} -
{song.album} -
- - {/* Song Info */} -
-

{song.title}

-

{song.artist} • {song.album}

-
- - {/* Duration */} -
- {formatDuration(song.duration)} -
- - {/* Actions */} -
- -
-
- ))} + {searchResults.songs.slice(0, 10).map((song, index) => { + const track = createTrackFromSong(song); + if (!track) return null; + + return ( + +
+
+ {index + 1} + +
+ + {/* Song Cover */} +
+ {song.album} +
+ + {/* Song Info */} +
+

{song.title}

+

{song.artist} • {song.album}

+
+ + {/* Duration */} +
+ {formatDuration(song.duration)} +
+ + {/* Actions */} +
+ +
+
+
+ ); + })} {searchResults.songs.length > 10 && (
diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts new file mode 100644 index 0000000..8196a94 --- /dev/null +++ b/hooks/use-keyboard-shortcuts.ts @@ -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 + }; +}