From 36c1edd01e99ba16981c0bf20981c5ecba3b17ac Mon Sep 17 00:00:00 2001 From: angel Date: Fri, 8 Aug 2025 21:29:01 +0000 Subject: [PATCH] feat: Add page transition animations and notification settings for audio playback --- .env.local | 2 +- app/components/AudioPlayer.tsx | 44 ++++++++++++++++ app/components/FullScreenPlayer.tsx | 79 ++++++++++++++++++++--------- app/components/PageTransition.tsx | 23 +++++++++ app/components/RootLayoutClient.tsx | 3 +- app/components/album-artwork.tsx | 17 ++++++- app/settings/page.tsx | 68 +++++++++++++++++++++++++ package.json | 2 +- pnpm-lock.yaml | 38 ++++++++++++++ 9 files changed, 246 insertions(+), 30 deletions(-) create mode 100644 app/components/PageTransition.tsx diff --git a/.env.local b/.env.local index 6f8a053..28a802d 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=0a0feb3 +NEXT_PUBLIC_COMMIT_SHA=3839a1b diff --git a/app/components/AudioPlayer.tsx b/app/components/AudioPlayer.tsx index 14decf0..d378a16 100644 --- a/app/components/AudioPlayer.tsx +++ b/app/components/AudioPlayer.tsx @@ -31,6 +31,8 @@ export const AudioPlayer: React.FC = () => { const [volume, setVolume] = useState(1); const [isClient, setIsClient] = useState(false); const [isMinimized, setIsMinimized] = useState(false); + // Notifications and title management + const [lastNotifiedTrackId, setLastNotifiedTrackId] = useState(null); const [isFullScreen, setIsFullScreen] = useState(false); const [audioInitialized, setAudioInitialized] = useState(false); const audioCurrent = audioRef.current; @@ -442,6 +444,48 @@ export const AudioPlayer: React.FC = () => { }; }, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause]); + // Update document title and optionally show a notification when a new song starts + useEffect(() => { + if (!isClient) return; + if (currentTrack) { + // Update favicon/title like Spotify + const baseTitle = `${currentTrack.name} • ${currentTrack.artist} – mice`; + document.title = isPlaying ? baseTitle : `(Paused) ${baseTitle}`; + + // Notifications + const notifyEnabled = localStorage.getItem('playback-notifications-enabled') === 'true'; + const canNotify = 'Notification' in window && Notification.permission !== 'denied'; + if (notifyEnabled && canNotify && lastNotifiedTrackId !== currentTrack.id) { + try { + if (Notification.permission === 'default') { + Notification.requestPermission().then((perm) => { + if (perm === 'granted') { + new Notification('Now Playing', { + body: `${currentTrack.name} — ${currentTrack.artist}`, + icon: currentTrack.coverArt || '/icon-192.png', + badge: '/icon-192.png', + }); + setLastNotifiedTrackId(currentTrack.id); + } + }); + } else if (Notification.permission === 'granted') { + new Notification('Now Playing', { + body: `${currentTrack.name} — ${currentTrack.artist}`, + icon: currentTrack.coverArt || '/icon-192.png', + badge: '/icon-192.png', + }); + setLastNotifiedTrackId(currentTrack.id); + } + } catch (e) { + console.warn('Notification failed:', e); + } + } + } else { + // Reset title when no track + document.title = 'mice'; + } + }, [currentTrack?.id, currentTrack?.name, currentTrack?.artist, isPlaying, isClient]); + // Media Session API integration - Enhanced for mobile useEffect(() => { if (!isClient || !currentTrack) return; diff --git a/app/components/FullScreenPlayer.tsx b/app/components/FullScreenPlayer.tsx index b783194..e8e93a0 100644 --- a/app/components/FullScreenPlayer.tsx +++ b/app/components/FullScreenPlayer.tsx @@ -1,6 +1,7 @@ -'use client'; +"use client"; import React, { useEffect, useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; @@ -410,15 +411,23 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl return `${mins}:${secs.toString().padStart(2, '0')}`; }; - if (!isOpen || !currentTrack) return null; + if (!currentTrack) return null; return ( -
+ + {isOpen && ( + {/* Enhanced Blurred background image */} {currentTrack.coverArt && ( -
+ {/* Main background */} -
= ({ isOpen, onCl filter: 'blur(20px) brightness(0.3)', transform: 'scale(1.1)', }} + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ duration: 0.3 }} /> {/* Top gradient blur for mobile */} -
= ({ isOpen, onCl transparent 100%)`, backdropFilter: 'blur(10px)', }} + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ duration: 0.25 }} /> {/* Bottom gradient blur for mobile */} -
= ({ isOpen, onCl transparent 100%)`, backdropFilter: 'blur(10px)', }} + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ duration: 0.25 }} /> -
+ )} {/* Overlay for better contrast */} -
+ -
+ {/* Mobile Close Handle */} {isMobile && ( -
+
-
+
-
+
)} {/* Desktop Header */} {!isMobile && ( -
+
{onOpenQueue && (
-
+ )} {/* Main Content */} @@ -502,8 +520,9 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl /* Mobile Tab Content */
+ {activeTab === 'player' && ( -
+ {/* Mobile Album Art */}
= ({ isOpen, onCl />
)} -
+ )} {activeTab === 'lyrics' && lyrics.length > 0 && ( -
+
= ({ isOpen, onCl
-
+ )} {activeTab === 'queue' && ( -
+
{queue.map((track, index) => ( @@ -690,8 +709,9 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl ))}
-
+ )} +
{/* Mobile Tab Bar */} @@ -857,8 +877,14 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl
{/* Right Side - Lyrics (Desktop Only) */} + {showLyrics && lyrics.length > 0 && ( -
+
@@ -890,12 +916,15 @@ export const FullScreenPlayer: React.FC = ({ isOpen, onCl
-
+ )} +
)}
-
-
+ + + )} + ); }; diff --git a/app/components/PageTransition.tsx b/app/components/PageTransition.tsx new file mode 100644 index 0000000..c543adb --- /dev/null +++ b/app/components/PageTransition.tsx @@ -0,0 +1,23 @@ +"use client"; + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { usePathname } from "next/navigation"; + +export default function PageTransition({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + return ( + + + {children} + + + ); +} diff --git a/app/components/RootLayoutClient.tsx b/app/components/RootLayoutClient.tsx index 4ace375..a299f95 100644 --- a/app/components/RootLayoutClient.tsx +++ b/app/components/RootLayoutClient.tsx @@ -13,6 +13,7 @@ import ThemeColorHandler from "./ThemeColorHandler"; import { useViewportThemeColor } from "@/hooks/use-viewport-theme-color"; import { LoginForm } from "./start-screen"; import Image from "next/image"; +import PageTransition from "./PageTransition"; // Service Worker registration if (typeof window !== 'undefined' && 'serviceWorker' in navigator) { @@ -102,7 +103,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo - {children} + {children} diff --git a/app/components/album-artwork.tsx b/app/components/album-artwork.tsx index 2e29a0e..e8b49a1 100644 --- a/app/components/album-artwork.tsx +++ b/app/components/album-artwork.tsx @@ -72,6 +72,17 @@ export function AlbumArtwork({ router.push(`/album/${album.id}`); }; + const handlePrefetch = () => { + try { + // Next.js App Router will prefetch on hover when using Link with prefetch + // but we also call router.prefetch to ensure programmatic prefetch. + // @ts-ignore - prefetch exists in next/navigation router in app router + if (router && typeof (router as any).prefetch === 'function') { + (router as any).prefetch(`/album/${album.id}`); + } + } catch {} + }; + const handleAddToQueue = () => { addAlbumToQueue(album.id); }; @@ -129,7 +140,7 @@ export function AlbumArtwork({
- handleClick()}> + handleClick()} onMouseEnter={handlePrefetch} onFocus={handlePrefetch}>
{album.coverArt && api && !offline.isOfflineMode ? (
-

{album.name}

+

+ {album.name} +

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

{album.songCount} songs • {Math.floor(album.duration / 60)} min diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 08a5d41..2d628d4 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -17,6 +17,7 @@ import { CacheManagement } from '@/app/components/CacheManagement'; import { OfflineManagement } from '@/app/components/OfflineManagement'; import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog } from 'react-icons/fa'; import { Settings, ExternalLink } from 'lucide-react'; +import { Switch } from '@/components/ui/switch'; const SettingsPage = () => { const { theme, setTheme, mode, setMode } = useTheme(); @@ -59,6 +60,7 @@ const SettingsPage = () => { // Sidebar settings const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarVisible, setSidebarVisible] = useState(true); + const [notifyNowPlaying, setNotifyNowPlaying] = useState(false); // Initialize client-side state after hydration useEffect(() => { @@ -94,6 +96,12 @@ const SettingsPage = () => { setSidebarVisible(true); // Default to visible } + // Notifications preference + const savedNotify = localStorage.getItem('playback-notifications-enabled'); + if (savedNotify !== null) { + setNotifyNowPlaying(savedNotify === 'true'); + } + // Load Last.fm credentials const storedCredentials = localStorage.getItem('lastfm-credentials'); if (storedCredentials) { @@ -264,6 +272,43 @@ const SettingsPage = () => { } }; + const handleNotifyToggle = async (enabled: boolean) => { + setNotifyNowPlaying(enabled); + if (isClient) { + localStorage.setItem('playback-notifications-enabled', enabled.toString()); + } + if (enabled && typeof window !== 'undefined' && 'Notification' in window) { + try { + if (Notification.permission === 'default') { + await Notification.requestPermission(); + } + } catch {} + } + toast({ + title: enabled ? 'Notifications Enabled' : 'Notifications Disabled', + description: enabled ? 'You will be notified when a new song starts.' : 'Now playing notifications are off.', + }); + }; + + const handleTestNotification = () => { + if (typeof window === 'undefined') return; + if (!('Notification' in window)) { + toast({ title: 'Not supported', description: 'Browser does not support notifications.', variant: 'destructive' }); + return; + } + if (Notification.permission === 'denied') { + toast({ title: 'Permission denied', description: 'Enable notifications in your browser settings.', variant: 'destructive' }); + return; + } + const title = 'mice – Test Notification'; + const body = 'This is how a now playing notification will look.'; + try { + new Notification(title, { body, icon: '/icon-192.png', badge: '/icon-192.png' }); + } catch { + toast({ title: 'Test Notification', description: body }); + } + }; + const handleLastFmAuth = () => { if (!lastFmCredentials.apiKey) { toast({ @@ -470,6 +515,29 @@ const SettingsPage = () => { )} + {/* Notifications */} + + + + + Notifications + + Control now playing notifications + + +

+
+

Now playing notifications

+

Show a notification when a new song starts

+
+ +
+
+ +
+
+
+ diff --git a/package.json b/package.json index f6d40f3..b9357d9 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "colorthief": "^2.6.0", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", + "framer-motion": "^11.18.2", "input-otp": "^1.4.2", "lucide-react": "^0.525.0", "next": "15.4.4", @@ -75,7 +76,6 @@ "@types/react": "19.1.8", "@types/react-dom": "19.1.6", "chalk": "^5.3.0", - "eslint": "^9.31", "eslint": "^9.32", "eslint-config-next": "15.4.5", "postcss": "^8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 689957e..d6f56a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.1.0) + framer-motion: + specifier: ^11.18.2 + version: 11.18.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -2313,6 +2316,20 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + framer-motion@11.18.2: + resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -2724,6 +2741,12 @@ packages: engines: {node: '>=10'} hasBin: true + motion-dom@11.18.1: + resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==} + + motion-utils@11.18.1: + resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -5526,6 +5549,15 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + framer-motion@11.18.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + motion-dom: 11.18.1 + motion-utils: 11.18.1 + tslib: 2.8.1 + optionalDependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -5908,6 +5940,12 @@ snapshots: mkdirp@3.0.1: {} + motion-dom@11.18.1: + dependencies: + motion-utils: 11.18.1 + + motion-utils@11.18.1: {} + ms@2.1.3: {} nanoid@3.3.11: {}