feat: enhance SongRecommendations component for mobile and desktop views, add Apple Touch Icons and viewport settings
This commit is contained in:
@@ -18,7 +18,9 @@ interface SongRecommendationsProps {
|
|||||||
export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||||
const { api, isConnected } = useNavidrome();
|
const { api, isConnected } = useNavidrome();
|
||||||
const { playTrack, shuffle, toggleShuffle } = useAudioPlayer();
|
const { playTrack, shuffle, toggleShuffle } = useAudioPlayer();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
|
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
|
||||||
|
const [recommendedAlbums, setRecommendedAlbums] = useState<Album[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [songStates, setSongStates] = useState<Record<string, boolean>>({});
|
const [songStates, setSongStates] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
@@ -43,40 +45,47 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Get random albums and extract songs from them
|
// Get random albums for both mobile album view and desktop song extraction
|
||||||
const randomAlbums = await api.getAlbums('random', 10); // Get 10 random albums
|
const randomAlbums = await api.getAlbums('random', 10);
|
||||||
const allSongs: Song[] = [];
|
|
||||||
|
|
||||||
// Get songs from first few albums
|
if (isMobile) {
|
||||||
for (let i = 0; i < Math.min(3, randomAlbums.length); i++) {
|
// For mobile: show 6 random albums
|
||||||
try {
|
setRecommendedAlbums(randomAlbums.slice(0, 6));
|
||||||
const albumSongs = await api.getAlbumSongs(randomAlbums[i].id);
|
} else {
|
||||||
allSongs.push(...albumSongs);
|
// For desktop: extract songs from albums (original behavior)
|
||||||
} catch (error) {
|
const allSongs: Song[] = [];
|
||||||
console.error('Failed to get album songs:', error);
|
|
||||||
|
// 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 for songs
|
||||||
|
const states: Record<string, boolean> = {};
|
||||||
|
recommendations.forEach((song: Song) => {
|
||||||
|
states[song.id] = !!song.starred;
|
||||||
|
});
|
||||||
|
setSongStates(states);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 only (removed image loading states)
|
|
||||||
const states: Record<string, boolean> = {};
|
|
||||||
recommendations.forEach((song: Song) => {
|
|
||||||
states[song.id] = !!song.starred;
|
|
||||||
});
|
|
||||||
setSongStates(states);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load song recommendations:', error);
|
console.error('Failed to load recommendations:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadRecommendations();
|
loadRecommendations();
|
||||||
}, [api, isConnected]);
|
}, [api, isConnected, isMobile]);
|
||||||
|
|
||||||
const handlePlaySong = async (song: Song) => {
|
const handlePlaySong = async (song: Song) => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
@@ -91,7 +100,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
|||||||
album: song.album || 'Unknown Album',
|
album: song.album || 'Unknown Album',
|
||||||
albumId: song.albumId || '',
|
albumId: song.albumId || '',
|
||||||
duration: song.duration || 0,
|
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
|
starred: !!song.starred
|
||||||
};
|
};
|
||||||
await playTrack(track, true);
|
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 () => {
|
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
|
// Enable shuffle if not already on
|
||||||
if (!shuffle) {
|
if (!shuffle) {
|
||||||
toggleShuffle();
|
toggleShuffle();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play a random song from recommendations
|
if (isMobile) {
|
||||||
const randomSong = recommendedSongs[Math.floor(Math.random() * recommendedSongs.length)];
|
// Play a random album
|
||||||
await handlePlaySong(randomSong);
|
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 => {
|
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-8 w-48 bg-muted animate-pulse rounded" />
|
||||||
<div className="h-4 w-64 bg-muted animate-pulse rounded" />
|
<div className="h-4 w-64 bg-muted animate-pulse rounded" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
{isMobile ? (
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
))}
|
<div key={i} className="aspect-square bg-muted animate-pulse rounded" />
|
||||||
</div>
|
))}
|
||||||
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -143,10 +193,10 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
|||||||
{greeting}{userName ? `, ${userName}` : ''}!
|
{greeting}{userName ? `, ${userName}` : ''}!
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{recommendedSongs.length > 0 && (
|
{(isMobile ? recommendedAlbums.length > 0 : recommendedSongs.length > 0) && (
|
||||||
<Button onClick={handleShuffleAll} variant="outline" size="sm">
|
<Button onClick={handleShuffleAll} variant="outline" size="sm">
|
||||||
<Shuffle className="w-4 h-4 mr-2" />
|
<Shuffle className="w-4 h-4 mr-2" />
|
||||||
Shuffle All
|
Shuffle All
|
||||||
@@ -154,76 +204,137 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recommendedSongs.length > 0 ? (
|
{isMobile ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
/* Mobile: Show albums in 3x2 grid */
|
||||||
{recommendedSongs.map((song) => (
|
recommendedAlbums.length > 0 ? (
|
||||||
<Card
|
<div className="grid grid-cols-3 gap-3">
|
||||||
key={song.id}
|
{recommendedAlbums.map((album) => (
|
||||||
className="group cursor-pointer hover:bg-accent/50 transition-colors py-2"
|
<Link
|
||||||
onClick={() => handlePlaySong(song)}
|
key={album.id}
|
||||||
>
|
href={`/album/${album.id}`}
|
||||||
<CardContent className="px-2">
|
className="group cursor-pointer"
|
||||||
<div className="flex items-center gap-3">
|
>
|
||||||
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0">
|
<div className="space-y-2">
|
||||||
{song.coverArt && api ? (
|
<div className="relative aspect-square rounded-lg overflow-hidden bg-muted">
|
||||||
|
{album.coverArt && api ? (
|
||||||
<>
|
<>
|
||||||
<Image
|
<Image
|
||||||
src={api.getCoverArtUrl(song.coverArt, 300)}
|
src={api.getCoverArtUrl(album.coverArt, 200)}
|
||||||
alt={song.title}
|
alt={album.name}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
sizes="48px"
|
sizes="(max-width: 768px) 33vw, 200px"
|
||||||
onLoad={handleImageLoad}
|
onLoad={handleImageLoad}
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
<Play className="w-4 h-4 text-white" />
|
<Play
|
||||||
|
className="w-8 h-8 text-white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePlayAlbum(album);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
<Music className="w-6 h-6 text-muted-foreground" />
|
<Music className="w-8 h-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
<div className="flex-1 min-w-0">
|
<p className="font-medium text-sm truncate">{album.name}</p>
|
||||||
<p className="font-medium truncate">{song.title}</p>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
</div>
|
||||||
<Link
|
</Link>
|
||||||
href={`/artist/${song.artistId}`}
|
))}
|
||||||
className="hover:underline truncate"
|
</div>
|
||||||
onClick={(e) => e.stopPropagation()}
|
) : (
|
||||||
>
|
<Card>
|
||||||
{song.artist}
|
<CardContent className="p-6 text-center">
|
||||||
</Link>
|
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
{song.duration && (
|
<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
|
||||||
|
key={song.id}
|
||||||
|
className="group cursor-pointer hover:bg-accent/50 transition-colors py-2"
|
||||||
|
onClick={() => handlePlaySong(song)}
|
||||||
|
>
|
||||||
|
<CardContent className="px-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0">
|
||||||
|
{song.coverArt && api ? (
|
||||||
<>
|
<>
|
||||||
<span>•</span>
|
<Image
|
||||||
<span>{formatDuration(song.duration)}</span>
|
src={api.getCoverArtUrl(song.coverArt, 48)}
|
||||||
|
alt={song.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="48px"
|
||||||
|
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-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<Music className="w-6 h-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{song.title}</p>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Link
|
||||||
|
href={`/artist/${song.artistId}`}
|
||||||
|
className="hover:underline truncate"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{song.artist}
|
||||||
|
</Link>
|
||||||
|
{song.duration && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatDuration(song.duration)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{songStates[song.id] && (
|
||||||
|
<Heart className="w-4 h-4 text-primary flex-shrink-0" fill="currentColor" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
{songStates[song.id] && (
|
</Card>
|
||||||
<Heart className="w-4 h-4 text-primary flex-shrink-0" fill="currentColor" />
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
</CardContent>
|
<Card>
|
||||||
</Card>
|
<CardContent className="p-6 text-center">
|
||||||
))}
|
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
</div>
|
<p className="text-muted-foreground">
|
||||||
) : (
|
No songs available for recommendations
|
||||||
<Card>
|
</p>
|
||||||
<CardContent className="p-6 text-center">
|
</CardContent>
|
||||||
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
</Card>
|
||||||
<p className="text-muted-foreground">
|
)
|
||||||
No songs available for recommendations
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,35 @@ export const metadata = {
|
|||||||
'max-snippet': -1,
|
'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({
|
const geistSans = localFont({
|
||||||
|
|||||||
@@ -38,6 +38,25 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
sizes: '512x512',
|
sizes: '512x512',
|
||||||
purpose: 'maskable'
|
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: [
|
screenshots: [
|
||||||
|
|||||||
Reference in New Issue
Block a user