From c246c2466a8af8f6b5387de382f74c958c1034dc Mon Sep 17 00:00:00 2001 From: angel Date: Thu, 19 Jun 2025 22:26:13 +0000 Subject: [PATCH 1/9] feat: implement auto-play functionality in AudioPlayer and update playTrack method in AudioPlayerContext --- app/api/test-navidrome/route.ts | 0 app/components/AudioPlayer.tsx | 34 +++++++++++++++++++++--- app/components/AudioPlayerContext.tsx | 16 ++++++----- app/components/FullScreenPlayer.tsx | 38 +++++++-------------------- 4 files changed, 50 insertions(+), 38 deletions(-) create mode 100644 app/api/test-navidrome/route.ts 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/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..a0fcc9f 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; @@ -92,14 +93,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 => { @@ -126,7 +130,7 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c if (queue.length > 0) { const nextTrack = queue[0]; setQueue((prevQueue) => prevQueue.slice(1)); - playTrack(nextTrack); + playTrack(nextTrack, true); // Auto-play next track } }, [queue, playTrack]); @@ -143,9 +147,9 @@ 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); diff --git a/app/components/FullScreenPlayer.tsx b/app/components/FullScreenPlayer.tsx index 8785b3a..4284fc2 100644 --- a/app/components/FullScreenPlayer.tsx +++ b/app/components/FullScreenPlayer.tsx @@ -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(() => { From 74ab4e212a4b0fffdba2e81e6a0b0fbbef968cc7 Mon Sep 17 00:00:00 2001 From: angel Date: Thu, 19 Jun 2025 22:35:45 +0000 Subject: [PATCH 2/9] feat: add lyric click functionality to jump to specific time in audio playback --- app/components/FullScreenPlayer.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/components/FullScreenPlayer.tsx b/app/components/FullScreenPlayer.tsx index 4284fc2..248711f 100644 --- a/app/components/FullScreenPlayer.tsx +++ b/app/components/FullScreenPlayer.tsx @@ -242,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); @@ -412,7 +426,8 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl
handleLyricClick(line.time)} + className={`text-sm 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' : index < currentLyricIndex @@ -426,6 +441,7 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl paddingBottom: '8px', paddingLeft: '9px' }} + title={`Click to jump to ${formatTime(line.time)}`} > {line.text || '♪'}
From ddd3986f0d711ce24b4d947efd1cb0bda2217cc8 Mon Sep 17 00:00:00 2001 From: angel Date: Thu, 19 Jun 2025 22:49:46 +0000 Subject: [PATCH 3/9] feat: implement infinite scroll and load more functionality for albums in BrowsePage --- app/browse/page.tsx | 99 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 9 deletions(-) diff --git a/app/browse/page.tsx b/app/browse/page.tsx index 2feaf63..9b0595d 100644 --- a/app/browse/page.tsx +++ b/app/browse/page.tsx @@ -1,18 +1,81 @@ /* 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 Loading from '@/app/components/loading'; export default function BrowsePage() { - const { albums, artists, isLoading } = useNavidrome(); + const { artists, isLoading: contextLoading } = useNavidrome(); + 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; + const newAlbums = await api.getAlbums('newest', 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 ; } @@ -52,10 +115,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 +126,38 @@ export default function BrowsePage() {
-
+
{albums.map((album) => ( ))}
+ {hasMoreAlbums && ( +
+ +
+ )} + {!hasMoreAlbums && albums.length > 0 && ( +
+

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

+
+ )}
- +
From 98b348bb34275103d2929c9aa1db27dcf2bd53f8 Mon Sep 17 00:00:00 2001 From: angel Date: Thu, 19 Jun 2025 22:58:49 +0000 Subject: [PATCH 4/9] feat: update album loading to support alphabetical order and adjust layout for better responsiveness --- app/browse/page.tsx | 10 ++++++---- app/components/album-artwork.tsx | 2 +- app/page.tsx | 8 ++++---- lib/navidrome.ts | 4 ++-- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/app/browse/page.tsx b/app/browse/page.tsx index 9b0595d..4aec2ad 100644 --- a/app/browse/page.tsx +++ b/app/browse/page.tsx @@ -26,7 +26,9 @@ export default function BrowsePage() { try { setIsLoadingAlbums(true); const offset = page * albumsPerPage; - const newAlbums = await api.getAlbums('newest', albumsPerPage, offset); + + // Use alphabeticalByName to get all albums in alphabetical order + const newAlbums = await api.getAlbums('alphabeticalByName', albumsPerPage, offset); if (append) { setAlbums(prev => [...prev, ...newAlbums]); @@ -126,7 +128,7 @@ export default function BrowsePage() {
-
+
{albums.map((album) => ( {hasMoreAlbums && ( -
+
- +
+
+
+
+
+ {artist.name} +
+
+

{artist.name}

+

{artist.albumCount} albums

+
+ + +
diff --git a/app/browse/page.tsx b/app/browse/page.tsx index 4aec2ad..751d0f3 100644 --- a/app/browse/page.tsx +++ b/app/browse/page.tsx @@ -10,10 +10,13 @@ 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 { artists, isLoading: contextLoading } = useNavidrome(); + const { shuffleAllAlbums } = useAudioPlayer(); const [albums, setAlbums] = useState([]); const [currentPage, setCurrentPage] = useState(0); const [isLoadingAlbums, setIsLoadingAlbums] = useState(false); @@ -86,6 +89,7 @@ export default function BrowsePage() { <> +

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

+
diff --git a/app/components/AudioPlayerContext.tsx b/app/components/AudioPlayerContext.tsx index a0fcc9f..e8244fd 100644 --- a/app/components/AudioPlayerContext.tsx +++ b/app/components/AudioPlayerContext.tsx @@ -33,6 +33,9 @@ interface AudioPlayerContextProps { addArtistToQueue: (artistId: string) => Promise; playPreviousTrack: () => void; isLoading: boolean; + shuffle: boolean; + toggleShuffle: () => void; + shuffleAllAlbums: () => Promise; } const AudioPlayerContext = createContext(undefined); @@ -42,6 +45,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(), []); @@ -128,11 +132,20 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c localStorage.removeItem('navidrome-current-track-time'); if (queue.length > 0) { - const nextTrack = queue[0]; - setQueue((prevQueue) => prevQueue.slice(1)); + let nextTrack; + if (shuffle) { + // Pick a random track from the queue + const randomIndex = Math.floor(Math.random() * queue.length); + nextTrack = queue[randomIndex]; + setQueue((prevQueue) => prevQueue.filter((_, i) => i !== randomIndex)); + } else { + // Pick the first track in order + nextTrack = queue[0]; + setQueue((prevQueue) => prevQueue.slice(1)); + } playTrack(nextTrack, true); // Auto-play next track } - }, [queue, playTrack]); + }, [queue, playTrack, shuffle]); const playPreviousTrack = useCallback(() => { // Clear saved timestamp when changing tracks @@ -276,6 +289,45 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c } }, [queue, playTrack]); + const toggleShuffle = useCallback(() => { + setShuffle(prev => !prev); + }, []); + + 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 contextValue = useMemo(() => ({ currentTrack, playTrack, @@ -290,7 +342,10 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c isLoading, playAlbum, playAlbumFromTrack, - skipToTrackInQueue + skipToTrackInQueue, + shuffle, + toggleShuffle, + shuffleAllAlbums }), [ currentTrack, queue, @@ -305,7 +360,10 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c playPreviousTrack, playAlbum, playAlbumFromTrack, - skipToTrackInQueue + skipToTrackInQueue, + shuffle, + toggleShuffle, + shuffleAllAlbums ]); return ( diff --git a/app/components/FullScreenPlayer.tsx b/app/components/FullScreenPlayer.tsx index 248711f..9025aa4 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); @@ -351,6 +351,16 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl {/* Controls */}
+ + + + + +
+ + {/* 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 c8cdd45..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'; @@ -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 From 8dfb4b34e5bbb2fe21245273946d61a3fca5f0e0 Mon Sep 17 00:00:00 2001 From: angel Date: Fri, 20 Jun 2025 00:32:31 +0000 Subject: [PATCH 6/9] feat: add auto-play flag to Track interface in AudioPlayerContext --- app/components/AudioPlayerContext.tsx | 162 ++++++++++++++++++++------ app/components/FullScreenPlayer.tsx | 4 +- app/queue/page.tsx | 25 +++- 3 files changed, 150 insertions(+), 41 deletions(-) diff --git a/app/components/AudioPlayerContext.tsx b/app/components/AudioPlayerContext.tsx index e8244fd..3b54100 100644 --- a/app/components/AudioPlayerContext.tsx +++ b/app/components/AudioPlayerContext.tsx @@ -116,8 +116,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([]); @@ -132,20 +143,13 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c localStorage.removeItem('navidrome-current-track-time'); if (queue.length > 0) { - let nextTrack; - if (shuffle) { - // Pick a random track from the queue - const randomIndex = Math.floor(Math.random() * queue.length); - nextTrack = queue[randomIndex]; - setQueue((prevQueue) => prevQueue.filter((_, i) => i !== randomIndex)); - } else { - // Pick the first track in order - nextTrack = queue[0]; - setQueue((prevQueue) => prevQueue.slice(1)); - } + // 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, true); // Auto-play next track } - }, [queue, playTrack, shuffle]); + }, [queue, playTrack]); const playPreviousTrack = useCallback(() => { // Clear saved timestamp when changing tracks @@ -169,7 +173,29 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c 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", @@ -185,20 +211,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`, @@ -213,24 +263,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); @@ -242,7 +304,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); @@ -257,15 +319,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); @@ -277,7 +352,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) { @@ -290,8 +365,25 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c }, [queue, playTrack]); const toggleShuffle = useCallback(() => { - setShuffle(prev => !prev); - }, []); + 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); diff --git a/app/components/FullScreenPlayer.tsx b/app/components/FullScreenPlayer.tsx index 9025aa4..869e871 100644 --- a/app/components/FullScreenPlayer.tsx +++ b/app/components/FullScreenPlayer.tsx @@ -354,9 +354,9 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl diff --git a/app/queue/page.tsx b/app/queue/page.tsx index 6f0ac1d..248327e 100644 --- a/app/queue/page.tsx +++ b/app/queue/page.tsx @@ -2,6 +2,7 @@ import React from 'react'; import Image from 'next/image'; +import Link from 'next/link'; import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; @@ -69,11 +70,15 @@ const QueuePage: React.FC = () => {
- {currentTrack.artist} + + {currentTrack.artist} +
- {currentTrack.album} + + {currentTrack.album} +
@@ -143,11 +148,23 @@ const QueuePage: React.FC = () => {
- {track.artist} + e.stopPropagation()} + > + {track.artist} +
- {track.album} + e.stopPropagation()} + > + {track.album} +
From e02ca64d17bcb6f3c90186fe6867a4804df135b9 Mon Sep 17 00:00:00 2001 From: angel Date: Fri, 20 Jun 2025 00:42:03 +0000 Subject: [PATCH 7/9] feat: implement playArtist function to play all albums by an artist and update QueuePage layout --- app/artist/[artist]/page.tsx | 16 +------- app/components/AudioPlayerContext.tsx | 56 ++++++++++++++++++++++++++- app/queue/page.tsx | 2 +- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/app/artist/[artist]/page.tsx b/app/artist/[artist]/page.tsx index f4873bc..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({ diff --git a/app/components/AudioPlayerContext.tsx b/app/components/AudioPlayerContext.tsx index 3b54100..51c8197 100644 --- a/app/components/AudioPlayerContext.tsx +++ b/app/components/AudioPlayerContext.tsx @@ -36,6 +36,7 @@ interface AudioPlayerContextProps { shuffle: boolean; toggleShuffle: () => void; shuffleAllAlbums: () => Promise; + playArtist: (artistId: string) => Promise; } const AudioPlayerContext = createContext(undefined); @@ -420,6 +421,55 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c } }, [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, @@ -437,7 +487,8 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c skipToTrackInQueue, shuffle, toggleShuffle, - shuffleAllAlbums + shuffleAllAlbums, + playArtist }), [ currentTrack, queue, @@ -455,7 +506,8 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c skipToTrackInQueue, shuffle, toggleShuffle, - shuffleAllAlbums + shuffleAllAlbums, + playArtist ]); return ( diff --git a/app/queue/page.tsx b/app/queue/page.tsx index 248327e..d0c8dc2 100644 --- a/app/queue/page.tsx +++ b/app/queue/page.tsx @@ -19,7 +19,7 @@ const QueuePage: React.FC = () => { }; return ( -
+
{/* Header */}
From 24a37e3368903c331e6a2ee517c2962ba316076a Mon Sep 17 00:00:00 2001 From: angel Date: Fri, 20 Jun 2025 00:48:10 +0000 Subject: [PATCH 8/9] feat: enhance FullScreenPlayer layout and responsiveness with improved styling for controls and lyrics display --- app/components/FullScreenPlayer.tsx | 58 +++++++++++++++-------------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/app/components/FullScreenPlayer.tsx b/app/components/FullScreenPlayer.tsx index 869e871..b4a468a 100644 --- a/app/components/FullScreenPlayer.tsx +++ b/app/components/FullScreenPlayer.tsx @@ -311,29 +311,33 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl {/* Main Content */}
{/* Left Side - Album Art and Controls */} -
+
0 + ? 'flex-1 justify-center lg:justify-start' + : 'flex-1 justify-center' + }`}> {/* Album Art */} -
+
{currentTrack.album}
{/* Track Info */} -
-

+
+

{currentTrack.name}

-

{currentTrack.artist}

+

{currentTrack.artist}

{/* Progress */} -
+
= ({ isOpen, onCl
{/* Controls */} -
+
- {/* Volume and Queue */} -
+ {/* Volume and Lyrics Toggle */} +
@@ -404,13 +408,13 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl }`} title={showLyrics ? 'Hide Lyrics' : 'Show Lyrics'} > - + )} {showVolumeSlider && (
setShowVolumeSlider(false)} > = ({ isOpen, onCl {/* Right Side - Lyrics */} {showLyrics && lyrics.length > 0 && ( -
+
- -
+ +
{lyrics.map((line, index) => (
handleLyricClick(line.time)} - className={`text-sm lg:text-base leading-relaxed transition-all duration-300 break-words cursor-pointer hover:text-foreground hover:scale-102 ${ + 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' @@ -448,8 +452,8 @@ 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)}`} > From b70d2325645dd36d48f2c8679bb34c5ae39f8445 Mon Sep 17 00:00:00 2001 From: angel Date: Fri, 20 Jun 2025 01:01:27 +0000 Subject: [PATCH 9/9] feat: improve FullScreenPlayer layout by enhancing responsiveness and adding lyrics toggle button --- app/components/FullScreenPlayer.tsx | 34 +++++++++++++++-------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/app/components/FullScreenPlayer.tsx b/app/components/FullScreenPlayer.tsx index b4a468a..f4374cf 100644 --- a/app/components/FullScreenPlayer.tsx +++ b/app/components/FullScreenPlayer.tsx @@ -288,7 +288,7 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl
{/* Header */}
-

Now Playing

+

{onOpenQueue && ( + + {lyrics.length > 0 && ( + + )} +
{/* Volume and Lyrics Toggle */} @@ -400,17 +413,6 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl )} - {lyrics.length > 0 && ( - - )} {showVolumeSlider && (
= ({ isOpen, onCl {/* Right Side - Lyrics */} {showLyrics && lyrics.length > 0 && ( -
+