feat: Enhance UI with Framer Motion animations for album artwork and artist icons
This commit is contained in:
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -523,18 +523,29 @@ 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 }}>
|
||||||
<Image
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
src={currentTrack.coverArt || '/default-album.png'}
|
<motion.div
|
||||||
alt={currentTrack.album}
|
key={currentTrack.id}
|
||||||
width={260}
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
height={260}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
className={`rounded-lg shadow-2xl object-cover transition-all duration-300 ${
|
exit={{ opacity: 0, scale: 1.02, position: 'absolute' as const }}
|
||||||
!isPlaying ? 'w-52 h-52 opacity-70 scale-95' : 'w-64 h-64'
|
transition={{ duration: 0.25 }}
|
||||||
}`}
|
className="flex items-center justify-center"
|
||||||
priority
|
>
|
||||||
/>
|
<Image
|
||||||
|
src={currentTrack.coverArt || '/default-album.png'}
|
||||||
|
alt={currentTrack.album}
|
||||||
|
width={260}
|
||||||
|
height={260}
|
||||||
|
className={`rounded-lg shadow-2xl object-cover transition-all duration-300 ${
|
||||||
|
!isPlaying ? 'w-52 h-52 opacity-70 scale-95' : 'w-64 h-64'
|
||||||
|
}`}
|
||||||
|
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,16 +767,27 @@ 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">
|
||||||
<Image
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
src={currentTrack.coverArt || '/default-album.png'}
|
<motion.div
|
||||||
alt={currentTrack.album}
|
key={currentTrack.id}
|
||||||
width={320}
|
className="absolute inset-0"
|
||||||
height={320}
|
initial={{ opacity: 0, scale: 0.985 }}
|
||||||
className="w-80 h-80 rounded-lg shadow-2xl object-cover"
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
priority
|
exit={{ opacity: 0, scale: 1.02 }}
|
||||||
/>
|
transition={{ duration: 0.25 }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={currentTrack.coverArt || '/default-album.png'}
|
||||||
|
alt={currentTrack.album}
|
||||||
|
width={320}
|
||||||
|
height={320}
|
||||||
|
className="w-80 h-80 rounded-lg shadow-2xl object-cover"
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user