feat: enhance SongRecommendations component for mobile and desktop views, add Apple Touch Icons and viewport settings

This commit is contained in:
2025-07-24 22:03:58 +00:00
committed by GitHub
parent abf29caacb
commit c8bc5e80d9
3 changed files with 244 additions and 85 deletions

View File

@@ -18,7 +18,9 @@ interface SongRecommendationsProps {
export function SongRecommendations({ userName }: SongRecommendationsProps) {
const { api, isConnected } = useNavidrome();
const { playTrack, shuffle, toggleShuffle } = useAudioPlayer();
const isMobile = useIsMobile();
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
const [recommendedAlbums, setRecommendedAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(true);
const [songStates, setSongStates] = useState<Record<string, boolean>>({});
@@ -43,8 +45,14 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
setLoading(true);
try {
// Get random albums and extract songs from them
const randomAlbums = await api.getAlbums('random', 10); // Get 10 random albums
// Get random albums for both mobile album view and desktop song extraction
const randomAlbums = await api.getAlbums('random', 10);
if (isMobile) {
// For mobile: show 6 random albums
setRecommendedAlbums(randomAlbums.slice(0, 6));
} else {
// For desktop: extract songs from albums (original behavior)
const allSongs: Song[] = [];
// Get songs from first few albums
@@ -62,21 +70,22 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
const recommendations = shuffled.slice(0, 6);
setRecommendedSongs(recommendations);
// Initialize starred states only (removed image loading states)
// Initialize starred states for songs
const states: Record<string, boolean> = {};
recommendations.forEach((song: Song) => {
states[song.id] = !!song.starred;
});
setSongStates(states);
}
} catch (error) {
console.error('Failed to load song recommendations:', error);
console.error('Failed to load recommendations:', error);
} finally {
setLoading(false);
}
};
loadRecommendations();
}, [api, isConnected]);
}, [api, isConnected, isMobile]);
const handlePlaySong = async (song: Song) => {
if (!api) return;
@@ -91,7 +100,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
album: song.album || 'Unknown Album',
albumId: song.albumId || '',
duration: song.duration || 0,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
starred: !!song.starred
};
await playTrack(track, true);
@@ -100,17 +109,50 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
}
};
const handlePlayAlbum = async (album: Album) => {
if (!api) return;
try {
// Get album songs and play the first one
const albumSongs = await api.getAlbumSongs(album.id);
if (albumSongs.length > 0) {
const track = {
id: albumSongs[0].id,
name: albumSongs[0].title,
url: api.getStreamUrl(albumSongs[0].id),
artist: albumSongs[0].artist || 'Unknown Artist',
artistId: albumSongs[0].artistId || '',
album: albumSongs[0].album || 'Unknown Album',
albumId: albumSongs[0].albumId || '',
duration: albumSongs[0].duration || 0,
coverArt: albumSongs[0].coverArt ? api.getCoverArtUrl(albumSongs[0].coverArt, 64) : undefined,
starred: !!albumSongs[0].starred
};
await playTrack(track, true);
}
} catch (error) {
console.error('Failed to play album:', error);
}
};
const handleShuffleAll = async () => {
if (recommendedSongs.length === 0) return;
if (isMobile && recommendedAlbums.length === 0) return;
if (!isMobile && recommendedSongs.length === 0) return;
// Enable shuffle if not already on
if (!shuffle) {
toggleShuffle();
}
if (isMobile) {
// Play a random album
const randomAlbum = recommendedAlbums[Math.floor(Math.random() * recommendedAlbums.length)];
await handlePlayAlbum(randomAlbum);
} else {
// Play a random song from recommendations
const randomSong = recommendedSongs[Math.floor(Math.random() * recommendedSongs.length)];
await handlePlaySong(randomSong);
}
};
const formatDuration = (duration: number): string => {
@@ -126,11 +168,19 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-64 bg-muted animate-pulse rounded" />
</div>
{isMobile ? (
<div className="grid grid-cols-3 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="aspect-square bg-muted animate-pulse rounded" />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
))}
</div>
)}
</div>
);
}
@@ -143,10 +193,10 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
{greeting}{userName ? `, ${userName}` : ''}!
</h2>
<p className="text-muted-foreground">
Here are some songs you might enjoy
{isMobile ? 'Here are some albums you might enjoy' : 'Here are some songs you might enjoy'}
</p>
</div>
{recommendedSongs.length > 0 && (
{(isMobile ? recommendedAlbums.length > 0 : recommendedSongs.length > 0) && (
<Button onClick={handleShuffleAll} variant="outline" size="sm">
<Shuffle className="w-4 h-4 mr-2" />
Shuffle All
@@ -154,7 +204,67 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
)}
</div>
{recommendedSongs.length > 0 ? (
{isMobile ? (
/* Mobile: Show albums in 3x2 grid */
recommendedAlbums.length > 0 ? (
<div className="grid grid-cols-3 gap-3">
{recommendedAlbums.map((album) => (
<Link
key={album.id}
href={`/album/${album.id}`}
className="group cursor-pointer"
>
<div className="space-y-2">
<div className="relative aspect-square rounded-lg overflow-hidden bg-muted">
{album.coverArt && api ? (
<>
<Image
src={api.getCoverArtUrl(album.coverArt, 200)}
alt={album.name}
fill
className="object-cover"
sizes="(max-width: 768px) 33vw, 200px"
onLoad={handleImageLoad}
onError={handleImageError}
loading="lazy"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Play
className="w-8 h-8 text-white"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handlePlayAlbum(album);
}}
/>
</div>
</>
) : (
<div className="w-full h-full flex items-center justify-center">
<Music className="w-8 h-8 text-muted-foreground" />
</div>
)}
</div>
<div className="space-y-1">
<p className="font-medium text-sm truncate">{album.name}</p>
</div>
</div>
</Link>
))}
</div>
) : (
<Card>
<CardContent className="p-6 text-center">
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
No albums available for recommendations
</p>
</CardContent>
</Card>
)
) : (
/* Desktop: Show songs in original format */
recommendedSongs.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{recommendedSongs.map((song) => (
<Card
@@ -168,7 +278,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
{song.coverArt && api ? (
<>
<Image
src={api.getCoverArtUrl(song.coverArt, 300)}
src={api.getCoverArtUrl(song.coverArt, 48)}
alt={song.title}
fill
className="object-cover"
@@ -224,6 +334,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
</p>
</CardContent>
</Card>
)
)}
</div>
);

View File

@@ -26,6 +26,35 @@ export const metadata = {
'max-snippet': -1,
},
},
viewport: {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
},
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: isDev && shortCommit ? `mice (dev: ${shortCommit})` : 'mice',
},
formatDetection: {
telephone: false,
},
other: {
'apple-mobile-web-app-capable': 'yes',
'apple-mobile-web-app-status-bar-style': 'black-translucent',
'format-detection': 'telephone=no',
},
icons: {
icon: [
{ url: '/favicon.ico', sizes: '48x48' },
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
],
apple: [
{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },
],
},
};
const geistSans = localFont({

View File

@@ -38,6 +38,25 @@ export default function manifest(): MetadataRoute.Manifest {
type: 'image/png',
sizes: '512x512',
purpose: 'maskable'
},
// Apple Touch Icons for iOS
{
src: '/apple-touch-icon.png',
type: 'image/png',
sizes: '180x180',
purpose: 'any'
},
{
src: '/icon-192.png',
type: 'image/png',
sizes: '152x152',
purpose: 'any'
},
{
src: '/icon-192.png',
type: 'image/png',
sizes: '120x120',
purpose: 'any'
}
],
screenshots: [