feat: Enhance UI with Framer Motion animations for album artwork and artist icons

This commit is contained in:
2025-08-08 21:38:58 +00:00
committed by GitHub
parent 437cb9db28
commit 4b0997c6b4
5 changed files with 100 additions and 36 deletions

View File

@@ -484,7 +484,7 @@ export const AudioPlayer: React.FC = () => {
// Reset title when no track // Reset title when no track
document.title = 'mice'; document.title = 'mice';
} }
}, [currentTrack?.id, currentTrack?.name, currentTrack?.artist, isPlaying, isClient]); }, [currentTrack?.id, currentTrack?.name, currentTrack?.artist, currentTrack?.coverArt, isPlaying, isClient, lastNotifiedTrackId]);
// Media Session API integration - Enhanced for mobile // Media Session API integration - Enhanced for mobile
useEffect(() => { useEffect(() => {

View File

@@ -3,6 +3,7 @@
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { Home, Search, Disc, Users, Music, Heart, List, Settings } from 'lucide-react'; import { Home, Search, Disc, Users, Music, Heart, List, Settings } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
interface NavItem { interface NavItem {
href: string; href: string;
@@ -40,7 +41,7 @@ export function BottomNavigation() {
const Icon = item.icon; const Icon = item.icon;
return ( return (
<button <motion.button
key={item.href} key={item.href}
onClick={() => handleNavigation(item.href)} onClick={() => handleNavigation(item.href)}
className={cn( className={cn(
@@ -50,6 +51,8 @@ export function BottomNavigation() {
? "text-primary bg-primary/10" ? "text-primary bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50" : "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)} )}
whileTap={{ scale: 0.95 }}
whileHover={{ y: -1 }}
> >
<Icon className={cn("w-5 h-5 mb-1", isItemActive && "text-primary")} /> <Icon className={cn("w-5 h-5 mb-1", isItemActive && "text-primary")} />
<span className={cn( <span className={cn(
@@ -58,7 +61,19 @@ export function BottomNavigation() {
)}> )}>
{item.label} {item.label}
</span> </span>
</button> <AnimatePresence>
{isItemActive && (
<motion.div
layoutId="bottom-nav-underline"
className="h-0.5 w-6 bg-primary mt-1 rounded"
initial={{ opacity: 0, scaleX: 0.6 }}
animate={{ opacity: 1, scaleX: 1 }}
exit={{ opacity: 0, scaleX: 0.6 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
</motion.button>
); );
})} })}
</div> </div>

View File

@@ -523,8 +523,17 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
<AnimatePresence mode="wait" initial={false}> <AnimatePresence mode="wait" initial={false}>
{activeTab === 'player' && ( {activeTab === 'player' && (
<motion.div key="tab-player" className="h-full flex flex-col justify-center items-center px-8 py-4" initial={{ x: 20, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: -20, opacity: 0 }} transition={{ duration: 0.2 }}> <motion.div key="tab-player" className="h-full flex flex-col justify-center items-center px-8 py-4" initial={{ x: 20, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: -20, opacity: 0 }} transition={{ duration: 0.2 }}>
{/* Mobile Album Art */} {/* Mobile Album Art (crossfade on track change) */}
<div className="relative mb-6 shrink-0"> <div className="relative mb-6 shrink-0 flex items-center justify-center" style={{ minHeight: 208 }}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={currentTrack.id}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.02, position: 'absolute' as const }}
transition={{ duration: 0.25 }}
className="flex items-center justify-center"
>
<Image <Image
src={currentTrack.coverArt || '/default-album.png'} src={currentTrack.coverArt || '/default-album.png'}
alt={currentTrack.album} alt={currentTrack.album}
@@ -535,6 +544,8 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
}`} }`}
priority priority
/> />
</motion.div>
</AnimatePresence>
</div> </div>
{/* Track Info - Left Aligned and Heart on Same Line */} {/* Track Info - Left Aligned and Heart on Same Line */}
@@ -651,11 +662,14 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
> >
<div className="space-y-3 py-4"> <div className="space-y-3 py-4">
{lyrics.map((line, index) => ( {lyrics.map((line, index) => (
<div <motion.div
key={index} key={index}
data-lyric-index={index} data-lyric-index={index}
onClick={() => handleLyricClick(line.time)} onClick={() => handleLyricClick(line.time)}
className={`text-base leading-relaxed transition-all duration-300 break-words cursor-pointer hover:text-foreground px-2 ${ initial={false}
animate={index === currentLyricIndex ? { scale: 1, opacity: 1 } : index < currentLyricIndex ? { scale: 0.995, opacity: 0.7 } : { scale: 0.99, opacity: 0.5 }}
transition={{ duration: 0.2 }}
className={`text-base leading-relaxed transition-colors duration-200 break-words cursor-pointer hover:text-foreground px-2 ${
index === currentLyricIndex index === currentLyricIndex
? 'text-foreground font-bold text-xl' ? 'text-foreground font-bold text-xl'
: index < currentLyricIndex : index < currentLyricIndex
@@ -671,7 +685,7 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
title={`Click to jump to ${formatTime(line.time)}`} title={`Click to jump to ${formatTime(line.time)}`}
> >
{line.text || '♪'} {line.text || '♪'}
</div> </motion.div>
))} ))}
<div style={{ height: '200px' }} /> <div style={{ height: '200px' }} />
</div> </div>
@@ -753,8 +767,17 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
<div className="h-full flex flex-row gap-8 p-6 overflow-hidden"> <div className="h-full flex flex-row gap-8 p-6 overflow-hidden">
{/* Left Side - Album Art and Controls */} {/* Left Side - Album Art and Controls */}
<div className="flex flex-col items-center justify-center min-h-0 flex-1 min-w-0"> <div className="flex flex-col items-center justify-center min-h-0 flex-1 min-w-0">
{/* Album Art */} {/* Album Art (crossfade on track change) */}
<div className="relative mb-6 shrink-0"> <div className="relative mb-6 shrink-0 w-80 h-80">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={currentTrack.id}
className="absolute inset-0"
initial={{ opacity: 0, scale: 0.985 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.02 }}
transition={{ duration: 0.25 }}
>
<Image <Image
src={currentTrack.coverArt || '/default-album.png'} src={currentTrack.coverArt || '/default-album.png'}
alt={currentTrack.album} alt={currentTrack.album}
@@ -763,6 +786,8 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
className="w-80 h-80 rounded-lg shadow-2xl object-cover" className="w-80 h-80 rounded-lg shadow-2xl object-cover"
priority priority
/> />
</motion.div>
</AnimatePresence>
</div> </div>
{/* Track Info */} {/* Track Info */}
@@ -889,11 +914,14 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
<ScrollArea className="flex-1 min-h-0"> <ScrollArea className="flex-1 min-h-0">
<div className="space-y-3 pl-4 pr-4 py-4"> <div className="space-y-3 pl-4 pr-4 py-4">
{lyrics.map((line, index) => ( {lyrics.map((line, index) => (
<div <motion.div
key={index} key={index}
data-lyric-index={index} data-lyric-index={index}
onClick={() => handleLyricClick(line.time)} onClick={() => handleLyricClick(line.time)}
className={`text-base leading-relaxed transition-all duration-300 break-words cursor-pointer hover:text-foreground ${ initial={false}
animate={index === currentLyricIndex ? { scale: 1, opacity: 1 } : index < currentLyricIndex ? { scale: 0.995, opacity: 0.75 } : { scale: 0.99, opacity: 0.5 }}
transition={{ duration: 0.2 }}
className={`text-base leading-relaxed transition-colors duration-200 break-words cursor-pointer hover:text-foreground ${
index === currentLyricIndex index === currentLyricIndex
? 'text-foreground font-bold text-2xl' ? 'text-foreground font-bold text-2xl'
: index < currentLyricIndex : index < currentLyricIndex
@@ -910,7 +938,7 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
title={`Click to jump to ${formatTime(line.time)}`} title={`Click to jump to ${formatTime(line.time)}`}
> >
{line.text || '♪'} {line.text || '♪'}
</div> </motion.div>
))} ))}
<div style={{ height: '200px' }} /> <div style={{ height: '200px' }} />
</div> </div>

View File

@@ -3,6 +3,7 @@
import Image from "next/image" import Image from "next/image"
import { PlusCircledIcon } from "@radix-ui/react-icons" import { PlusCircledIcon } from "@radix-ui/react-icons"
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
@@ -29,7 +30,10 @@ import { Heart, Music, Disc, Mic, Play, Download } from "lucide-react";
import { Album, Artist, Song } from "@/lib/navidrome"; import { Album, Artist, Song } from "@/lib/navidrome";
import { OfflineIndicator } from "@/app/components/OfflineIndicator"; import { OfflineIndicator } from "@/app/components/OfflineIndicator";
interface AlbumArtworkProps extends React.HTMLAttributes<HTMLDivElement> { interface AlbumArtworkProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
'onDrag' | 'onDragStart' | 'onDragEnd' | 'onDragOver' | 'onDragEnter' | 'onDragLeave' | 'onDrop'
> {
album: Album album: Album
aspectRatio?: "portrait" | "square" aspectRatio?: "portrait" | "square"
width?: number width?: number
@@ -75,10 +79,10 @@ export function AlbumArtwork({
const handlePrefetch = () => { const handlePrefetch = () => {
try { try {
// Next.js App Router will prefetch on hover when using Link with prefetch // Next.js App Router will prefetch on hover when using Link with prefetch
// but we also call router.prefetch to ensure programmatic prefetch. // but we also call router.prefetch to ensure programmatic prefetch when present.
// @ts-ignore - prefetch exists in next/navigation router in app router const r = router as unknown as { prefetch?: (href: string) => Promise<void> | void };
if (router && typeof (router as any).prefetch === 'function') { if (r && typeof r.prefetch === 'function') {
(router as any).prefetch(`/album/${album.id}`); r.prefetch(`/album/${album.id}`);
} }
} catch {} } catch {}
}; };
@@ -138,6 +142,13 @@ export function AlbumArtwork({
return ( return (
<div className={cn("space-y-3", className)} {...props}> <div className={cn("space-y-3", className)} {...props}>
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.15 }}
transition={{ duration: 0.2 }}
whileHover={{ y: -2 }}
>
<ContextMenu> <ContextMenu>
<ContextMenuTrigger> <ContextMenuTrigger>
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()} onMouseEnter={handlePrefetch} onFocus={handlePrefetch}> <Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()} onMouseEnter={handlePrefetch} onFocus={handlePrefetch}>
@@ -239,6 +250,7 @@ export function AlbumArtwork({
<ContextMenuItem>Share</ContextMenuItem> <ContextMenuItem>Share</ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
</motion.div>
</div> </div>
) )
} }

View File

@@ -4,6 +4,7 @@ import Image from "next/image"
import { PlusCircledIcon } from "@radix-ui/react-icons" import { PlusCircledIcon } from "@radix-ui/react-icons"
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { motion } from 'framer-motion'
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -88,6 +89,13 @@ export function ArtistIcon({
<div className={cn("space-y-3", className)} {...props}> <div className={cn("space-y-3", className)} {...props}>
<ContextMenu> <ContextMenu>
<ContextMenuTrigger> <ContextMenuTrigger>
<motion.div
initial={{ opacity: 0, y: 8 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.2 }}
whileHover={{ y: -2 }}
>
<Card key={artist.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}> <Card key={artist.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}>
<div <div
className="aspect-square relative group" className="aspect-square relative group"
@@ -118,6 +126,7 @@ export function ArtistIcon({
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</motion.div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="w-40"> <ContextMenuContent className="w-40">
<ContextMenuItem onClick={handleStar}> <ContextMenuItem onClick={handleStar}>