diff --git a/app/api/test-navidrome/route.ts b/app/api/test-navidrome/route.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/artist/[artist]/page.tsx b/app/artist/[artist]/page.tsx index 1bca727..c475db9 100644 --- a/app/artist/[artist]/page.tsx +++ b/app/artist/[artist]/page.tsx @@ -21,7 +21,7 @@ export default function ArtistPage() { const [artist, setArtist] = useState(null); const [isPlayingArtist, setIsPlayingArtist] = useState(false); const { getArtist, starItem, unstarItem } = useNavidrome(); - const { addArtistToQueue, playAlbum, clearQueue } = useAudioPlayer(); + const { playArtist } = useAudioPlayer(); const { toast } = useToast(); const api = getNavidromeAPI(); @@ -65,19 +65,7 @@ export default function ArtistPage() { setIsPlayingArtist(true); try { - // Clear current queue and add all artist albums - clearQueue(); - await addArtistToQueue(artist.id); - - // Start playing the first album if we have any - if (artistAlbums.length > 0) { - await playAlbum(artistAlbums[0].id); - } - - toast({ - title: "Playing Artist", - description: `Now playing all albums by ${artist.name}`, - }); + await playArtist(artist.id); } catch (error) { console.error('Failed to play artist:', error); toast({ @@ -108,36 +96,42 @@ export default function ArtistPage() { : '/default-user.jpg'; return ( -
+
{/* Artist Header */} -
-
-
- {artist.name} -
-
-

{artist.name}

-

{artist.albumCount} albums

-
- - +
+
+
+
+
+ {artist.name} +
+
+

{artist.name}

+

{artist.albumCount} albums

+
+ + +
diff --git a/app/browse/page.tsx b/app/browse/page.tsx index 2feaf63..751d0f3 100644 --- a/app/browse/page.tsx +++ b/app/browse/page.tsx @@ -1,18 +1,86 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ 'use client'; +import { useState, useEffect } from 'react'; import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; +import { Button } from '@/components/ui/button'; import { Tabs, TabsContent } from '@/components/ui/tabs'; import { AlbumArtwork } from '@/app/components/album-artwork'; import { ArtistIcon } from '@/app/components/artist-icon'; import { useNavidrome } from '@/app/components/NavidromeContext'; +import { getNavidromeAPI, Album } from '@/lib/navidrome'; +import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; +import { Shuffle } from 'lucide-react'; import Loading from '@/app/components/loading'; export default function BrowsePage() { - const { albums, artists, isLoading } = useNavidrome(); + const { artists, isLoading: contextLoading } = useNavidrome(); + const { shuffleAllAlbums } = useAudioPlayer(); + const [albums, setAlbums] = useState([]); + const [currentPage, setCurrentPage] = useState(0); + const [isLoadingAlbums, setIsLoadingAlbums] = useState(false); + const [hasMoreAlbums, setHasMoreAlbums] = useState(true); + const albumsPerPage = 84; - if (isLoading) { + const api = getNavidromeAPI(); + + const loadAlbums = async (page: number, append: boolean = false) => { + try { + setIsLoadingAlbums(true); + const offset = page * albumsPerPage; + + // Use alphabeticalByName to get all albums in alphabetical order + const newAlbums = await api.getAlbums('alphabeticalByName', albumsPerPage, offset); + + if (append) { + setAlbums(prev => [...prev, ...newAlbums]); + } else { + setAlbums(newAlbums); + } + + // If we got fewer albums than requested, we've reached the end + setHasMoreAlbums(newAlbums.length === albumsPerPage); + } catch (error) { + console.error('Failed to load albums:', error); + } finally { + setIsLoadingAlbums(false); + } + }; + + useEffect(() => { + loadAlbums(0); + }, []); + + // Infinite scroll handler + useEffect(() => { + const handleScroll = (e: Event) => { + const target = e.target as HTMLElement; + if (!target || isLoadingAlbums || !hasMoreAlbums) return; + + const { scrollTop, scrollHeight, clientHeight } = target; + const threshold = 200; // Load more when 200px from bottom + + if (scrollHeight - scrollTop - clientHeight < threshold) { + loadMore(); + } + }; + + const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]'); + if (scrollArea) { + scrollArea.addEventListener('scroll', handleScroll); + return () => scrollArea.removeEventListener('scroll', handleScroll); + } + }, [isLoadingAlbums, hasMoreAlbums, currentPage]); + + const loadMore = () => { + if (isLoadingAlbums || !hasMoreAlbums) return; + const nextPage = currentPage + 1; + setCurrentPage(nextPage); + loadAlbums(nextPage, true); + }; + + if (contextLoading) { return ; } @@ -21,6 +89,7 @@ export default function BrowsePage() { <> +

@@ -30,6 +99,10 @@ export default function BrowsePage() { the people who make the music

+
@@ -52,10 +125,10 @@ export default function BrowsePage() {

- Browse + Browse Albums

- Browse the full collection of music available. + Browse the full collection of albums ({albums.length} loaded).

@@ -63,20 +136,38 @@ export default function BrowsePage() {
-
+
{albums.map((album) => ( ))}
+ {hasMoreAlbums && ( +
+ +
+ )} + {!hasMoreAlbums && albums.length > 0 && ( +
+

+ All albums loaded ({albums.length} total) +

+
+ )}
- +
diff --git a/app/components/AudioPlayer.tsx b/app/components/AudioPlayer.tsx index 353c595..bd0dd56 100644 --- a/app/components/AudioPlayer.tsx +++ b/app/components/AudioPlayer.tsx @@ -84,8 +84,17 @@ export const AudioPlayer: React.FC = () => { localStorage.removeItem('navidrome-current-track-time'); } - audioCurrent.play(); - setIsPlaying(true); + // Auto-play only if the track has the autoPlay flag + if (currentTrack.autoPlay) { + audioCurrent.play().then(() => { + setIsPlaying(true); + }).catch((error) => { + console.error('Failed to auto-play:', error); + setIsPlaying(false); + }); + } else { + setIsPlaying(false); + } } }, [currentTrack]); @@ -121,11 +130,21 @@ export const AudioPlayer: React.FC = () => { lastSavedTime = audioCurrent.currentTime; } }; + + const handlePlay = () => { + setIsPlaying(true); + }; + + const handlePause = () => { + setIsPlaying(false); + }; if (audioCurrent) { audioCurrent.addEventListener('timeupdate', updateProgress); audioCurrent.addEventListener('ended', handleTrackEnd); audioCurrent.addEventListener('seeked', handleSeeked); + audioCurrent.addEventListener('play', handlePlay); + audioCurrent.addEventListener('pause', handlePause); } return () => { @@ -133,6 +152,8 @@ export const AudioPlayer: React.FC = () => { audioCurrent.removeEventListener('timeupdate', updateProgress); audioCurrent.removeEventListener('ended', handleTrackEnd); audioCurrent.removeEventListener('seeked', handleSeeked); + audioCurrent.removeEventListener('play', handlePlay); + audioCurrent.removeEventListener('pause', handlePause); } }; }, [playNextTrack, currentTrack]); @@ -213,10 +234,15 @@ export const AudioPlayer: React.FC = () => { if (audioCurrent) { if (isPlaying) { audioCurrent.pause(); + setIsPlaying(false); } else { - audioCurrent.play(); + audioCurrent.play().then(() => { + setIsPlaying(true); + }).catch((error) => { + console.error('Failed to play audio:', error); + setIsPlaying(false); + }); } - setIsPlaying(!isPlaying); } }; const handleVolumeChange = (e: React.ChangeEvent) => { diff --git a/app/components/AudioPlayerContext.tsx b/app/components/AudioPlayerContext.tsx index dee80e1..51c8197 100644 --- a/app/components/AudioPlayerContext.tsx +++ b/app/components/AudioPlayerContext.tsx @@ -15,11 +15,12 @@ interface Track { coverArt?: string; albumId: string; artistId: string; + autoPlay?: boolean; // Flag to control auto-play } interface AudioPlayerContextProps { currentTrack: Track | null; - playTrack: (track: Track) => void; + playTrack: (track: Track, autoPlay?: boolean) => void; queue: Track[]; addToQueue: (track: Track) => void; playNextTrack: () => void; @@ -32,6 +33,10 @@ interface AudioPlayerContextProps { addArtistToQueue: (artistId: string) => Promise; playPreviousTrack: () => void; isLoading: boolean; + shuffle: boolean; + toggleShuffle: () => void; + shuffleAllAlbums: () => Promise; + playArtist: (artistId: string) => Promise; } const AudioPlayerContext = createContext(undefined); @@ -41,6 +46,7 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c const [queue, setQueue] = useState([]); const [playedTracks, setPlayedTracks] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [shuffle, setShuffle] = useState(false); const { toast } = useToast(); const api = useMemo(() => getNavidromeAPI(), []); @@ -92,14 +98,17 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c }; }, [api]); - const playTrack = useCallback((track: Track) => { + const playTrack = useCallback((track: Track, autoPlay: boolean = false) => { // Clear saved timestamp when manually playing a track localStorage.removeItem('navidrome-current-track-time'); if (currentTrack) { setPlayedTracks((prev) => [...prev, currentTrack]); } - setCurrentTrack(track); + + // Set autoPlay flag on the track + const trackWithAutoPlay = { ...track, autoPlay }; + setCurrentTrack(trackWithAutoPlay); // Scrobble the track api.scrobble(track.id).catch(error => { @@ -108,8 +117,19 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c }, [currentTrack, api]); const addToQueue = useCallback((track: Track) => { - setQueue((prevQueue) => [...prevQueue, track]); - }, []); + setQueue((prevQueue) => { + if (shuffle && prevQueue.length > 0) { + // If shuffle is enabled, insert the track at a random position + const randomIndex = Math.floor(Math.random() * (prevQueue.length + 1)); + const newQueue = [...prevQueue]; + newQueue.splice(randomIndex, 0, track); + return newQueue; + } else { + // Normal behavior: add to the end + return [...prevQueue, track]; + } + }); + }, [shuffle]); const clearQueue = useCallback(() => { setQueue([]); @@ -124,9 +144,11 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c localStorage.removeItem('navidrome-current-track-time'); if (queue.length > 0) { + // Always pick the first track from the queue + // If shuffle is enabled, the queue will already be shuffled const nextTrack = queue[0]; setQueue((prevQueue) => prevQueue.slice(1)); - playTrack(nextTrack); + playTrack(nextTrack, true); // Auto-play next track } }, [queue, playTrack]); @@ -143,16 +165,38 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c setQueue((prevQueue) => [currentTrack, ...prevQueue]); } - setCurrentTrack(previousTrack); + playTrack(previousTrack, true); // Auto-play previous track } - }, [playedTracks, currentTrack]); + }, [playedTracks, currentTrack, playTrack]); const addAlbumToQueue = useCallback(async (albumId: string) => { setIsLoading(true); try { const { album, songs } = await api.getAlbum(albumId); const tracks = songs.map(songToTrack); - setQueue((prevQueue) => [...prevQueue, ...tracks]); + + setQueue((prevQueue) => { + if (shuffle && prevQueue.length > 0) { + // If shuffle is enabled, shuffle the new tracks and insert them randomly + const shuffledTracks = [...tracks]; + // Fisher-Yates shuffle algorithm + for (let i = shuffledTracks.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffledTracks[i], shuffledTracks[j]] = [shuffledTracks[j], shuffledTracks[i]]; + } + + // Insert each track at a random position + const newQueue = [...prevQueue]; + shuffledTracks.forEach(track => { + const randomIndex = Math.floor(Math.random() * (newQueue.length + 1)); + newQueue.splice(randomIndex, 0, track); + }); + return newQueue; + } else { + // Normal behavior: add to the end + return [...prevQueue, ...tracks]; + } + }); toast({ title: "Album Added", @@ -168,20 +212,44 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c } finally { setIsLoading(false); } - }, [api, songToTrack, toast]); + }, [api, songToTrack, toast, shuffle]); const addArtistToQueue = useCallback(async (artistId: string) => { setIsLoading(true); try { const { artist, albums } = await api.getArtist(artistId); + let allTracks: Track[] = []; - // Add all albums from this artist to queue + // Collect all tracks from all albums for (const album of albums) { const { songs } = await api.getAlbum(album.id); const tracks = songs.map(songToTrack); - setQueue((prevQueue) => [...prevQueue, ...tracks]); + allTracks = allTracks.concat(tracks); } + setQueue((prevQueue) => { + if (shuffle && prevQueue.length > 0) { + // If shuffle is enabled, shuffle the new tracks and insert them randomly + const shuffledTracks = [...allTracks]; + // Fisher-Yates shuffle algorithm + for (let i = shuffledTracks.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffledTracks[i], shuffledTracks[j]] = [shuffledTracks[j], shuffledTracks[i]]; + } + + // Insert each track at a random position + const newQueue = [...prevQueue]; + shuffledTracks.forEach(track => { + const randomIndex = Math.floor(Math.random() * (newQueue.length + 1)); + newQueue.splice(randomIndex, 0, track); + }); + return newQueue; + } else { + // Normal behavior: add to the end + return [...prevQueue, ...allTracks]; + } + }); + toast({ title: "Artist Added", description: `Added all albums by "${artist.name}" to queue`, @@ -196,24 +264,36 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c } finally { setIsLoading(false); } - }, [api, songToTrack, toast]); + }, [api, songToTrack, toast, shuffle]); const playAlbum = useCallback(async (albumId: string) => { setIsLoading(true); try { const { album, songs } = await api.getAlbum(albumId); const tracks = songs.map(songToTrack); - // Clear the queue and set the new tracks - setQueue(tracks.slice(1)); // All tracks except the first one - - // Play the first track immediately if (tracks.length > 0) { - playTrack(tracks[0]); + if (shuffle) { + // If shuffle is enabled, shuffle the tracks + const shuffledTracks = [...tracks]; + // Fisher-Yates shuffle algorithm + for (let i = shuffledTracks.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffledTracks[i], shuffledTracks[j]] = [shuffledTracks[j], shuffledTracks[i]]; + } + + // Play the first shuffled track and set the rest as queue + playTrack(shuffledTracks[0]); + setQueue(shuffledTracks.slice(1)); + } else { + // Normal order: play first track and set the rest as queue + playTrack(tracks[0]); + setQueue(tracks.slice(1)); + } } toast({ title: "Playing Album", - description: `Now playing "${album.name}"`, + description: `Now playing "${album.name}"${shuffle ? ' (shuffled)' : ''}`, }); } catch (error) { console.error('Failed to play album:', error); @@ -225,7 +305,7 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c } finally { setIsLoading(false); } - }, [api, playTrack, songToTrack, toast]); + }, [api, playTrack, songToTrack, toast, shuffle]); const playAlbumFromTrack = useCallback(async (albumId: string, startingSongId: string) => { setIsLoading(true); @@ -240,15 +320,28 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c throw new Error('Starting song not found in album'); } - // Clear the queue and set the remaining tracks after the starting track - setQueue(tracks.slice(startingIndex + 1)); - - // Play the starting track immediately - playTrack(tracks[startingIndex]); + if (shuffle) { + // If shuffle is enabled, create a shuffled queue but start with the selected track + const remainingTracks = [...tracks]; + remainingTracks.splice(startingIndex, 1); // Remove the starting track + + // Shuffle the remaining tracks + for (let i = remainingTracks.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [remainingTracks[i], remainingTracks[j]] = [remainingTracks[j], remainingTracks[i]]; + } + + setQueue(remainingTracks); + playTrack(tracks[startingIndex]); + } else { + // Normal order: set the remaining tracks after the starting track as queue + setQueue(tracks.slice(startingIndex + 1)); + playTrack(tracks[startingIndex]); + } toast({ title: "Playing Album", - description: `Playing "${album.name}" from "${tracks[startingIndex].name}"`, + description: `Playing "${album.name}" from "${tracks[startingIndex].name}"${shuffle ? ' (shuffled)' : ''}`, }); } catch (error) { console.error('Failed to play album from track:', error); @@ -260,7 +353,7 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c } finally { setIsLoading(false); } - }, [api, playTrack, songToTrack, toast]); + }, [api, playTrack, songToTrack, toast, shuffle]); const skipToTrackInQueue = useCallback((index: number) => { if (index >= 0 && index < queue.length) { @@ -272,6 +365,111 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c } }, [queue, playTrack]); + const toggleShuffle = useCallback(() => { + setShuffle(prev => { + const newShuffleState = !prev; + + // If turning shuffle ON, shuffle the current queue + if (newShuffleState && queue.length > 0) { + setQueue(prevQueue => { + const shuffledQueue = [...prevQueue]; + // Fisher-Yates shuffle algorithm + for (let i = shuffledQueue.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffledQueue[i], shuffledQueue[j]] = [shuffledQueue[j], shuffledQueue[i]]; + } + return shuffledQueue; + }); + } + + return newShuffleState; + }); + }, [queue.length]); + + const shuffleAllAlbums = useCallback(async () => { + setIsLoading(true); + try { + const albums = await api.getAlbums('alphabeticalByName', 500, 0); + let allTracks: Track[] = []; + + // Concatenate all tracks from each album into a single array + for (const album of albums) { + const { songs } = await api.getAlbum(album.id); + const tracks = songs.map(songToTrack); + allTracks = allTracks.concat(tracks); + } + + // Shuffle the combined tracks array + allTracks.sort(() => Math.random() - 0.5); + + // Set the shuffled tracks as the new queue + setQueue(allTracks); + + toast({ + title: "Shuffle All Albums", + description: `Shuffled ${allTracks.length} tracks from all albums`, + }); + } catch (error) { + console.error('Failed to shuffle all albums:', error); + toast({ + variant: "destructive", + title: "Error", + description: "Failed to shuffle all albums", + }); + } finally { + setIsLoading(false); + } + }, [api, songToTrack, toast]); + + const playArtist = useCallback(async (artistId: string) => { + setIsLoading(true); + try { + const { artist, albums } = await api.getArtist(artistId); + let allTracks: Track[] = []; + + // Collect all tracks from all albums + for (const album of albums) { + const { songs } = await api.getAlbum(album.id); + const tracks = songs.map(songToTrack); + allTracks = allTracks.concat(tracks); + } + + if (allTracks.length > 0) { + if (shuffle) { + // If shuffle is enabled, shuffle all tracks + const shuffledTracks = [...allTracks]; + // Fisher-Yates shuffle algorithm + for (let i = shuffledTracks.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffledTracks[i], shuffledTracks[j]] = [shuffledTracks[j], shuffledTracks[i]]; + } + + // Play the first shuffled track and set the rest as queue + playTrack(shuffledTracks[0]); + setQueue(shuffledTracks.slice(1)); + } else { + // Normal order: play first track and set the rest as queue + playTrack(allTracks[0]); + setQueue(allTracks.slice(1)); + } + } + + toast({ + title: "Playing Artist", + description: `Now playing all albums by "${artist.name}"${shuffle ? ' (shuffled)' : ''}`, + }); + } catch (error) { + console.error('Failed to play artist:', error); + toast({ + variant: "destructive", + title: "Error", + description: "Failed to play artist albums", + }); + } finally { + setIsLoading(false); + } + }, [api, songToTrack, toast, shuffle, playTrack]); + const contextValue = useMemo(() => ({ currentTrack, playTrack, @@ -286,7 +484,11 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c isLoading, playAlbum, playAlbumFromTrack, - skipToTrackInQueue + skipToTrackInQueue, + shuffle, + toggleShuffle, + shuffleAllAlbums, + playArtist }), [ currentTrack, queue, @@ -301,7 +503,11 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c playPreviousTrack, playAlbum, playAlbumFromTrack, - skipToTrackInQueue + skipToTrackInQueue, + shuffle, + toggleShuffle, + shuffleAllAlbums, + playArtist ]); return ( diff --git a/app/components/FullScreenPlayer.tsx b/app/components/FullScreenPlayer.tsx index 8785b3a..f4374cf 100644 --- a/app/components/FullScreenPlayer.tsx +++ b/app/components/FullScreenPlayer.tsx @@ -33,7 +33,7 @@ interface FullScreenPlayerProps { } export const FullScreenPlayer: React.FC = ({ isOpen, onClose, onOpenQueue }) => { - const { currentTrack, playPreviousTrack, playNextTrack } = useAudioPlayer(); + const { currentTrack, playPreviousTrack, playNextTrack, shuffle, toggleShuffle } = useAudioPlayer(); const [progress, setProgress] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [volume, setVolume] = useState(1); @@ -139,53 +139,35 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl // Sync with main audio player (improved responsiveness) useEffect(() => { - let lastUpdate = 0; - const throttleMs = 100; // Update at most every 100ms for better responsiveness - const syncWithMainPlayer = () => { - const now = Date.now(); - if (now - lastUpdate < throttleMs) return; - lastUpdate = now; - const mainAudio = document.querySelector('audio') as HTMLAudioElement; if (mainAudio && currentTrack) { const newCurrentTime = mainAudio.currentTime; const newDuration = mainAudio.duration || 0; const newIsPlaying = !mainAudio.paused; - // Always update playing state for better responsiveness - if (newIsPlaying !== isPlaying) { - setIsPlaying(newIsPlaying); - } + // Always update playing state immediately + setIsPlaying(newIsPlaying); + setCurrentTime(newCurrentTime); + setDuration(newDuration); + setVolume(mainAudio.volume); - // Only update state if values have changed significantly - if (Math.abs(newCurrentTime - currentTime) > 0.3) { - setCurrentTime(newCurrentTime); - } - if (Math.abs(newDuration - duration) > 0.1) { - setDuration(newDuration); - } if (newDuration > 0) { const newProgress = (newCurrentTime / newDuration) * 100; - if (Math.abs(newProgress - progress) > 0.1) { - setProgress(newProgress); - } - } - if (Math.abs(mainAudio.volume - volume) > 0.01) { - setVolume(mainAudio.volume); + setProgress(newProgress); } } }; - if (isOpen) { + if (isOpen && currentTrack) { // Initial sync syncWithMainPlayer(); - // Set up interval to keep syncing - more frequent for better responsiveness - const interval = setInterval(syncWithMainPlayer, 50); + // Set up interval to keep syncing + const interval = setInterval(syncWithMainPlayer, 100); return () => clearInterval(interval); } - }, [isOpen, currentTrack]); // Removed other dependencies to prevent loop + }, [isOpen, currentTrack?.id]); // React to track changes // Extract dominant color from cover art useEffect(() => { @@ -260,6 +242,20 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl setVolume(newVolume); }; + const handleLyricClick = (time: number) => { + const mainAudio = document.querySelector('audio') as HTMLAudioElement; + if (!mainAudio) return; + + mainAudio.currentTime = time; + setCurrentTime(time); + + // Update progress bar as well + if (duration > 0) { + const newProgress = (time / duration) * 100; + setProgress(newProgress); + } + }; + const formatTime = (seconds: number) => { if (!seconds || isNaN(seconds)) return '0:00'; const mins = Math.floor(seconds / 60); @@ -292,7 +288,7 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl
{/* Header */}
-

Now Playing

+

{onOpenQueue && ( + -
- {/* Volume and Queue */} -
- - {lyrics.length > 0 && ( )} +
+ + {/* Volume and Lyrics Toggle */} +
+ + + {showVolumeSlider && (
setShowVolumeSlider(false)} > = ({ isOpen, onCl {/* Right Side - Lyrics */} {showLyrics && lyrics.length > 0 && ( -
+
- -
+ +
{lyrics.map((line, index) => (
handleLyricClick(line.time)} + className={`text-sm sm:text-base lg:text-base leading-relaxed transition-all duration-300 break-words cursor-pointer hover:text-foreground hover:scale-102 ${ index === currentLyricIndex - ? 'text-foreground font-semibold text-lg lg:text-xl scale-105' + ? 'text-foreground font-semibold text-base sm:text-lg lg:text-xl scale-105' : index < currentLyricIndex ? 'text-foreground/60' : 'text-foreground/40' @@ -441,9 +454,10 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl wordWrap: 'break-word', overflowWrap: 'break-word', hyphens: 'auto', - paddingBottom: '8px', - paddingLeft: '9px' + paddingBottom: '6px', + paddingLeft: '8px' }} + title={`Click to jump to ${formatTime(line.time)}`} > {line.text || '♪'}
diff --git a/app/components/NavidromeContext.tsx b/app/components/NavidromeContext.tsx index 93bc931..728771d 100644 --- a/app/components/NavidromeContext.tsx +++ b/app/components/NavidromeContext.tsx @@ -1,6 +1,6 @@ 'use client'; import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react'; -import { getNavidromeAPI, Album, Artist, Song, Playlist } from '@/lib/navidrome'; +import { getNavidromeAPI, Album, Artist, Song, Playlist, AlbumInfo, ArtistInfo } from '@/lib/navidrome'; import { useCallback } from 'react'; interface NavidromeContextType { @@ -23,8 +23,11 @@ interface NavidromeContextType { // Methods searchMusic: (query: string) => Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }>; + search2: (query: string) => Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }>; getAlbum: (albumId: string) => Promise<{ album: Album; songs: Song[] }>; getArtist: (artistId: string) => Promise<{ artist: Artist; albums: Album[] }>; + getArtistInfo2: (artistId: string) => Promise; + getAlbumInfo2: (albumId: string) => Promise; getPlaylist: (playlistId: string) => Promise<{ playlist: Playlist; songs: Song[] }>; getAllSongs: () => Promise; refreshData: () => Promise; @@ -123,6 +126,17 @@ export const NavidromeProvider: React.FC = ({ children } } }; + const search2 = async (query: string) => { + setError(null); + try { + return await api.search2(query); + } catch (err) { + console.error('Search2 failed:', err); + setError('Search failed'); + return { artists: [], albums: [], songs: [] }; + } + }; + const getAlbum = async (albumId: string) => { setError(null); try { @@ -145,6 +159,28 @@ export const NavidromeProvider: React.FC = ({ children } } }; + const getArtistInfo2 = async (artistId: string) => { + setError(null); + try { + return await api.getArtistInfo2(artistId); + } catch (err) { + console.error('Failed to get artist info:', err); + setError('Failed to get artist info'); + throw err; + } + }; + + const getAlbumInfo2 = async (albumId: string) => { + setError(null); + try { + return await api.getAlbumInfo2(albumId); + } catch (err) { + console.error('Failed to get album info:', err); + setError('Failed to get album info'); + throw err; + } + }; + const getPlaylist = async (playlistId: string) => { setError(null); try { @@ -276,8 +312,11 @@ export const NavidromeProvider: React.FC = ({ children } // Methods searchMusic, + search2, getAlbum, getArtist, + getArtistInfo2, + getAlbumInfo2, getPlaylist, getAllSongs, refreshData, diff --git a/app/components/album-artwork.tsx b/app/components/album-artwork.tsx index 181b2bc..51ab653 100644 --- a/app/components/album-artwork.tsx +++ b/app/components/album-artwork.tsx @@ -75,7 +75,7 @@ export function AlbumArtwork({ height={height} className={cn( - "h-auto w-auto object-cover transition-all hover:scale-105", + "w-full h-full object-cover transition-all hover:scale-105", aspectRatio === "portrait" ? "aspect-[3/4]" : "aspect-square" )} /> diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx index 8b56f05..f7dc603 100644 --- a/app/components/sidebar.tsx +++ b/app/components/sidebar.tsx @@ -15,6 +15,7 @@ interface SidebarProps extends React.HTMLAttributes { export function Sidebar({ className, playlists }: SidebarProps) { const isRoot = usePathname() === "/"; const isBrowse = usePathname() === "/browse"; + const isSearch = usePathname() === "/search"; const isAlbums = usePathname() === "/library/albums"; const isArtists = usePathname() === "/library/artists"; const isQueue = usePathname() === "/queue"; @@ -68,6 +69,24 @@ export function Sidebar({ className, playlists }: SidebarProps) { Browse + + + +
+ + {/* Song Cover */} +
+ {song.album} +
+ + {/* Song Info */} +
+

{song.title}

+

{song.artist} • {song.album}

+
+ + {/* Duration */} +
+ {formatDuration(song.duration)} +
+ + {/* Actions */} +
+ +
+
+ ))} + + {searchResults.songs.length > 10 && ( +
+

+ Showing first 10 of {searchResults.songs.length} songs +

+
+ )} +
+
+ )} +
+ )} + + {/* Empty State */} + {!searchQuery && ( +
+ +

Search your music

+

+ Find your favorite artists, albums, and songs +

+
+ )} +
+
+ ); +} diff --git a/lib/navidrome.ts b/lib/navidrome.ts index ac4e842..15806c7 100644 --- a/lib/navidrome.ts +++ b/lib/navidrome.ts @@ -89,6 +89,26 @@ export interface RadioStation { homePageUrl?: string; } +export interface AlbumInfo { + notes?: string; + musicBrainzId?: string; + lastFmUrl?: string; + smallImageUrl?: string; + mediumImageUrl?: string; + largeImageUrl?: string; + biography?: string; +} + +export interface ArtistInfo { + biography?: string; + musicBrainzId?: string; + lastFmUrl?: string; + smallImageUrl?: string; + mediumImageUrl?: string; + largeImageUrl?: string; + similarArtist?: Artist[]; +} + class NavidromeAPI { private config: NavidromeConfig; private clientName = 'stillnavidrome'; @@ -106,7 +126,7 @@ class NavidromeAPI { return crypto.createHash('md5').update(password + salt).digest('hex'); } - private async makeRequest(endpoint: string, params: Record = {}): Promise> { + async makeRequest(endpoint: string, params: Record = {}): Promise> { const salt = this.generateSalt(); const token = this.generateToken(this.config.password, salt); @@ -175,7 +195,7 @@ class NavidromeAPI { }; } - async getAlbums(type?: 'newest' | 'recent' | 'frequent' | 'random', size: number = 50, offset: number = 0): Promise { + async getAlbums(type?: 'newest' | 'recent' | 'frequent' | 'random' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'starred' | 'highest', size: number = 500, offset: number = 0): Promise { const response = await this.makeRequest('getAlbumList2', { type: type || 'newest', size, @@ -365,6 +385,67 @@ class NavidromeAPI { async deleteInternetRadioStation(id: string): Promise { await this.makeRequest('deleteInternetRadioStation', { id }); } + + async getArtistInfo(artistId: string): Promise<{ artist: Artist; info: ArtistInfo }> { + const response = await this.makeRequest('getArtistInfo2', { id: artistId }); + const artistData = response.artist as Artist; + const artistInfo = response.info as ArtistInfo; + return { + artist: artistData, + info: artistInfo + }; + } + + async getAlbumInfo(albumId: string): Promise<{ album: Album; info: AlbumInfo }> { + const response = await this.makeRequest('getAlbumInfo2', { id: albumId }); + const albumData = response.album as Album; + const albumInfo = response.info as AlbumInfo; + return { + album: albumData, + info: albumInfo + }; + } + + async search2(query: string, artistCount = 20, albumCount = 20, songCount = 20): Promise<{ + artists: Artist[]; + albums: Album[]; + songs: Song[]; + }> { + const response = await this.makeRequest('search2', { + query, + artistCount, + albumCount, + songCount + }); + + const searchData = response.searchResult2 as { + artist?: Artist[]; + album?: Album[]; + song?: Song[]; + }; + + return { + artists: searchData?.artist || [], + albums: searchData?.album || [], + songs: searchData?.song || [] + }; + } + + async getArtistInfo2(artistId: string, count = 20, includeNotPresent = false): Promise { + const response = await this.makeRequest('getArtistInfo2', { + id: artistId, + count, + includeNotPresent: includeNotPresent.toString() + }); + return response.artistInfo2 as ArtistInfo; + } + + async getAlbumInfo2(albumId: string): Promise { + const response = await this.makeRequest('getAlbumInfo2', { + id: albumId + }); + return response.albumInfo2 as AlbumInfo; + } } // Singleton instance management