From 3c13c13143c2e7dd1c0acd193dfeab9e2773a697 Mon Sep 17 00:00:00 2001 From: angel Date: Thu, 10 Jul 2025 19:55:02 +0000 Subject: [PATCH] feat: add song recommendations component with loading state and shuffle functionality --- .env.local | 2 +- app/components/RootLayoutClient.tsx | 21 ++- app/components/SongRecommendations.tsx | 213 +++++++++++++++++++++++++ app/components/album-artwork.tsx | 58 +++++-- app/page.tsx | 79 ++++----- 5 files changed, 314 insertions(+), 59 deletions(-) create mode 100644 app/components/SongRecommendations.tsx diff --git a/.env.local b/.env.local index 164ebbd..b985cf2 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=da58c49 +NEXT_PUBLIC_COMMIT_SHA=52e465d diff --git a/app/components/RootLayoutClient.tsx b/app/components/RootLayoutClient.tsx index 72a5cf9..9233724 100644 --- a/app/components/RootLayoutClient.tsx +++ b/app/components/RootLayoutClient.tsx @@ -14,15 +14,19 @@ import Image from "next/image"; function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) { const { error } = useNavidrome(); + const [isClient, setIsClient] = React.useState(false); + const [hasCompletedOnboarding, setHasCompletedOnboarding] = React.useState(true); // Default to true to prevent flash - // Check if this is a first-time user - const hasCompletedOnboarding = typeof window !== 'undefined' - ? localStorage.getItem('onboarding-completed') - : false; + // Client-side hydration + React.useEffect(() => { + setIsClient(true); + const onboardingStatus = localStorage.getItem('onboarding-completed'); + setHasCompletedOnboarding(!!onboardingStatus); + }, []); // Simple check: has config in localStorage or environment const hasAnyConfig = React.useMemo(() => { - if (typeof window === 'undefined') return false; + if (!isClient) return true; // Assume config exists during SSR to prevent flash // Check localStorage config const savedConfig = localStorage.getItem('navidrome-config'); @@ -45,7 +49,12 @@ function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) { } return false; - }, []); + }, [isClient]); + + // Don't show anything until client-side hydration is complete + if (!isClient) { + return <>{children}; + } // Show start screen ONLY if: // 1. First-time user (no onboarding completed), OR diff --git a/app/components/SongRecommendations.tsx b/app/components/SongRecommendations.tsx new file mode 100644 index 0000000..3dab12d --- /dev/null +++ b/app/components/SongRecommendations.tsx @@ -0,0 +1,213 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Song } from '@/lib/navidrome'; +import { useNavidrome } from '@/app/components/NavidromeContext'; +import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Play, Heart, Music, Shuffle } from 'lucide-react'; +import Image from 'next/image'; +import Link from 'next/link'; + +interface SongRecommendationsProps { + userName?: string; +} + +export function SongRecommendations({ userName }: SongRecommendationsProps) { + const { api, isConnected } = useNavidrome(); + const { playTrack, shuffle, toggleShuffle } = useAudioPlayer(); + const [recommendedSongs, setRecommendedSongs] = useState([]); + const [loading, setLoading] = useState(true); + const [songStates, setSongStates] = useState>({}); + + // Get greeting based on time of day + const hour = new Date().getHours(); + const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'; + + useEffect(() => { + const loadRecommendations = async () => { + if (!api || !isConnected) return; + + setLoading(true); + try { + // Get random albums and extract songs from them + const randomAlbums = await api.getAlbums('random', 10); // Get 10 random albums + const allSongs: Song[] = []; + + // Get songs from first few albums + for (let i = 0; i < Math.min(3, randomAlbums.length); i++) { + try { + const albumSongs = await api.getAlbumSongs(randomAlbums[i].id); + allSongs.push(...albumSongs); + } catch (error) { + console.error('Failed to get album songs:', error); + } + } + + // Shuffle and limit to 6 songs + const shuffled = allSongs.sort(() => Math.random() - 0.5); + const recommendations = shuffled.slice(0, 6); + setRecommendedSongs(recommendations); + + // Initialize starred states + const states: Record = {}; + recommendations.forEach((song: Song) => { + states[song.id] = !!song.starred; + }); + setSongStates(states); + } catch (error) { + console.error('Failed to load song recommendations:', error); + } finally { + setLoading(false); + } + }; + + loadRecommendations(); + }, [api, isConnected]); + + const handlePlaySong = async (song: Song) => { + if (!api) return; + + try { + const track = { + id: song.id, + name: song.title, + url: api.getStreamUrl(song.id), + artist: song.artist || 'Unknown Artist', + artistId: song.artistId || '', + album: song.album || 'Unknown Album', + albumId: song.albumId || '', + duration: song.duration || 0, + coverArt: song.coverArt, + starred: !!song.starred + }; + await playTrack(track, true); + } catch (error) { + console.error('Failed to play song:', error); + } + }; + + const handleShuffleAll = async () => { + if (recommendedSongs.length === 0) return; + + // Enable shuffle if not already on + if (!shuffle) { + toggleShuffle(); + } + + // Play a random song from recommendations + const randomSong = recommendedSongs[Math.floor(Math.random() * recommendedSongs.length)]; + await handlePlaySong(randomSong); + }; + + const formatDuration = (duration: number): string => { + const minutes = Math.floor(duration / 60); + const seconds = duration % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + + if (loading) { + return ( +
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+
+ ); + } + + return ( +
+
+
+

+ {greeting}{userName ? `, ${userName}` : ''}! +

+

+ Here are some songs you might enjoy +

+
+ {recommendedSongs.length > 0 && ( + + )} +
+ + {recommendedSongs.length > 0 ? ( +
+ {recommendedSongs.map((song) => ( + handlePlaySong(song)} + > + +
+
+ {song.coverArt && api ? ( + {song.title} + ) : ( +
+ +
+ )} +
+ +
+
+ +
+

{song.title}

+
+ e.stopPropagation()} + > + {song.artist} + + {song.duration && ( + <> + + {formatDuration(song.duration)} + + )} +
+
+ + {songStates[song.id] && ( + + )} +
+
+
+ ))} +
+ ) : ( + + + +

+ No songs available for recommendations +

+
+
+ )} +
+ ); +} diff --git a/app/components/album-artwork.tsx b/app/components/album-artwork.tsx index 373ca55..78062a4 100644 --- a/app/components/album-artwork.tsx +++ b/app/components/album-artwork.tsx @@ -46,6 +46,8 @@ export function AlbumArtwork({ const router = useRouter(); const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer(); const { playlists, starItem, unstarItem } = useNavidrome(); + const [imageLoading, setImageLoading] = useState(true); + const [imageError, setImageError] = useState(false); const handleClick = () => { router.push(`/album/${album.id}`); @@ -115,28 +117,54 @@ export function AlbumArtwork({ handleClick()}>
{album.coverArt && api ? ( - {album.name} + <> + {imageLoading && ( +
+ +
+ )} + {album.name} setImageLoading(false)} + onError={() => { + setImageLoading(false); + setImageError(true); + }} + /> + ) : (
)} -
- handlePlayAlbum(album)}/> -
+ {!imageLoading && ( +
+ handlePlayAlbum(album)}/> +
+ )}
-

{album.name}

-

router.push(album.artistId)}>{album.artist}

-

- {album.songCount} songs • {Math.floor(album.duration / 60)} min -

+ {imageLoading ? ( + <> +
+
+
+ + ) : ( + <> +

{album.name}

+

router.push(album.artistId)}>{album.artist}

+

+ {album.songCount} songs • {Math.floor(album.duration / 60)} min +

+ + )} {/*
diff --git a/app/page.tsx b/app/page.tsx index 9616637..5b86eb4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,14 +5,17 @@ import { Separator } from '../components/ui/separator'; import { Tabs, TabsContent } from '../components/ui/tabs'; import { AlbumArtwork } from './components/album-artwork'; import { useNavidrome } from './components/NavidromeContext'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, Suspense } from 'react'; import { Album } from '@/lib/navidrome'; import { useNavidromeConfig } from './components/NavidromeConfigContext'; import { useSearchParams } from 'next/navigation'; import { useAudioPlayer } from './components/AudioPlayerContext'; +import { SongRecommendations } from './components/SongRecommendations'; +import { Skeleton } from '@/components/ui/skeleton'; type TimeOfDay = 'morning' | 'afternoon' | 'evening'; -export default function MusicPage() { + +function MusicPageContent() { const { albums, isLoading, api, isConnected } = useNavidrome(); const { playAlbum, playTrack, shuffle, toggleShuffle, addToQueue } = useAudioPlayer(); const searchParams = useSearchParams(); @@ -158,19 +161,6 @@ export default function MusicPage() { return () => clearTimeout(timeout); }, [searchParams, api, isConnected, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue]); - // Get greeting and time of day - const hour = new Date().getHours(); - const greeting = hour < 12 ? 'Good morning' : 'Good afternoon'; - let timeOfDay: TimeOfDay; - if (hour >= 5 && hour < 12) { - timeOfDay = 'morning'; - } else if (hour >= 12 && hour < 18) { - timeOfDay = 'afternoon'; - } else { - timeOfDay = 'evening'; - } - - // Try to get user name from navidrome context, fallback to 'user' let userName = ''; // If you add user info to NavidromeContext, update this logic @@ -182,25 +172,11 @@ export default function MusicPage() { return (
-
-
-
-
-
-

{greeting}{userName ? `, ${userName}` : ''}!

-
-
-
-
+ {/* Song Recommendations Section */} +
+ +
+ <> @@ -221,7 +197,14 @@ export default function MusicPage() { {isLoading ? ( // Loading skeletons Array.from({ length: 10 }).map((_, i) => ( -
+
+ +
+ + + +
+
)) ) : ( recentAlbums.map((album) => ( @@ -258,7 +241,14 @@ export default function MusicPage() { {favoritesLoading ? ( // Loading skeletons Array.from({ length: 10 }).map((_, i) => ( -
+
+ +
+ + + +
+
)) ) : ( favoriteAlbums.map((album) => ( @@ -294,7 +284,14 @@ export default function MusicPage() { {isLoading ? ( // Loading skeletons Array.from({ length: 10 }).map((_, i) => ( -
+
+ +
+ + + +
+
)) ) : ( newestAlbums.map((album) => ( @@ -317,4 +314,12 @@ export default function MusicPage() {
); +} + +export default function MusicPage() { + return ( + Loading...
}> + + + ); } \ No newline at end of file