feat: Add page transition animations and notification settings for audio playback
This commit is contained in:
@@ -1 +1 @@
|
|||||||
NEXT_PUBLIC_COMMIT_SHA=0a0feb3
|
NEXT_PUBLIC_COMMIT_SHA=3839a1b
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
const [volume, setVolume] = useState(1);
|
const [volume, setVolume] = useState(1);
|
||||||
const [isClient, setIsClient] = useState(false);
|
const [isClient, setIsClient] = useState(false);
|
||||||
const [isMinimized, setIsMinimized] = useState(false);
|
const [isMinimized, setIsMinimized] = useState(false);
|
||||||
|
// Notifications and title management
|
||||||
|
const [lastNotifiedTrackId, setLastNotifiedTrackId] = useState<string | null>(null);
|
||||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||||
const [audioInitialized, setAudioInitialized] = useState(false);
|
const [audioInitialized, setAudioInitialized] = useState(false);
|
||||||
const audioCurrent = audioRef.current;
|
const audioCurrent = audioRef.current;
|
||||||
@@ -442,6 +444,48 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause]);
|
}, [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
|
// Media Session API integration - Enhanced for mobile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isClient || !currentTrack) return;
|
if (!isClient || !currentTrack) return;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||||
@@ -410,15 +411,23 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOpen || !currentTrack) return null;
|
if (!currentTrack) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[70] bg-black overflow-hidden">
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-[70] bg-black overflow-hidden"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
{/* Enhanced Blurred background image */}
|
{/* Enhanced Blurred background image */}
|
||||||
{currentTrack.coverArt && (
|
{currentTrack.coverArt && (
|
||||||
<div className="absolute inset-0 w-full h-full">
|
<motion.div className="absolute inset-0 w-full h-full" initial={{ scale: 1.02 }} animate={{ scale: 1.08 }} transition={{ duration: 10, ease: 'linear' }}>
|
||||||
{/* Main background */}
|
{/* Main background */}
|
||||||
<div
|
<motion.div
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${currentTrack.coverArt})`,
|
backgroundImage: `url(${currentTrack.coverArt})`,
|
||||||
@@ -428,9 +437,12 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
filter: 'blur(20px) brightness(0.3)',
|
filter: 'blur(20px) brightness(0.3)',
|
||||||
transform: 'scale(1.1)',
|
transform: 'scale(1.1)',
|
||||||
}}
|
}}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
/>
|
/>
|
||||||
{/* Top gradient blur for mobile */}
|
{/* Top gradient blur for mobile */}
|
||||||
<div
|
<motion.div
|
||||||
className="absolute top-0 left-0 right-0 h-32"
|
className="absolute top-0 left-0 right-0 h-32"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(to bottom,
|
background: `linear-gradient(to bottom,
|
||||||
@@ -439,9 +451,12 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
transparent 100%)`,
|
transparent 100%)`,
|
||||||
backdropFilter: 'blur(10px)',
|
backdropFilter: 'blur(10px)',
|
||||||
}}
|
}}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
/>
|
/>
|
||||||
{/* Bottom gradient blur for mobile */}
|
{/* Bottom gradient blur for mobile */}
|
||||||
<div
|
<motion.div
|
||||||
className="absolute bottom-0 left-0 right-0 h-32"
|
className="absolute bottom-0 left-0 right-0 h-32"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(to top,
|
background: `linear-gradient(to top,
|
||||||
@@ -450,31 +465,34 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
transparent 100%)`,
|
transparent 100%)`,
|
||||||
backdropFilter: 'blur(10px)',
|
backdropFilter: 'blur(10px)',
|
||||||
}}
|
}}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Overlay for better contrast */}
|
{/* Overlay for better contrast */}
|
||||||
<div className="absolute inset-0 bg-black/30" />
|
<motion.div className="absolute inset-0 bg-black/30" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} />
|
||||||
|
|
||||||
<div className="relative h-full w-full flex flex-col">
|
<motion.div className="relative h-full w-full flex flex-col" initial={{ y: 10, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: 10, opacity: 0 }} transition={{ duration: 0.2, ease: 'easeOut' }}>
|
||||||
|
|
||||||
{/* Mobile Close Handle */}
|
{/* Mobile Close Handle */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="flex justify-center py-4 px-4">
|
<motion.div className="flex justify-center py-4 px-4" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.2 }}>
|
||||||
<div
|
<div
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="cursor-pointer px-8 py-3 -mx-8 -my-3"
|
className="cursor-pointer px-8 py-3 -mx-8 -my-3"
|
||||||
style={{ touchAction: 'manipulation' }}
|
style={{ touchAction: 'manipulation' }}
|
||||||
>
|
>
|
||||||
<div className="w-8 h-1 bg-gray-300 rounded-full opacity-60" />
|
<motion.div className="w-8 h-1 bg-gray-300 rounded-full opacity-60" initial={{ scaleX: 0.9 }} animate={{ scaleX: 1 }} transition={{ duration: 0.3 }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop Header */}
|
{/* Desktop Header */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="absolute top-0 right-0 z-10 p-4 lg:p-6">
|
<motion.div className="absolute top-0 right-0 z-10 p-4 lg:p-6" initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2 }}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{onOpenQueue && (
|
{onOpenQueue && (
|
||||||
<button
|
<button
|
||||||
@@ -493,7 +511,7 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
<FaXmark className="w-5 h-5" />
|
<FaXmark className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
@@ -502,8 +520,9 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
/* Mobile Tab Content */
|
/* Mobile Tab Content */
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
{activeTab === 'player' && (
|
{activeTab === 'player' && (
|
||||||
<div className="h-full flex flex-col justify-center items-center px-8 py-4">
|
<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 */}
|
||||||
<div className="relative mb-6 shrink-0">
|
<div className="relative mb-6 shrink-0">
|
||||||
<Image
|
<Image
|
||||||
@@ -621,11 +640,11 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'lyrics' && lyrics.length > 0 && (
|
{activeTab === 'lyrics' && lyrics.length > 0 && (
|
||||||
<div className="h-full flex flex-col px-4">
|
<motion.div key="tab-lyrics" className="h-full flex flex-col px-4" initial={{ x: 20, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: -20, opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-y-auto"
|
className="flex-1 overflow-y-auto"
|
||||||
ref={lyricsRef}
|
ref={lyricsRef}
|
||||||
@@ -657,11 +676,11 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
<div style={{ height: '200px' }} />
|
<div style={{ height: '200px' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'queue' && (
|
{activeTab === 'queue' && (
|
||||||
<div className="h-full flex flex-col px-4">
|
<motion.div key="tab-queue" className="h-full flex flex-col px-4" initial={{ x: 20, opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: -20, opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="space-y-2 py-4">
|
<div className="space-y-2 py-4">
|
||||||
{queue.map((track, index) => (
|
{queue.map((track, index) => (
|
||||||
@@ -690,8 +709,9 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Tab Bar */}
|
{/* Mobile Tab Bar */}
|
||||||
@@ -857,8 +877,14 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Side - Lyrics (Desktop Only) */}
|
{/* Right Side - Lyrics (Desktop Only) */}
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
{showLyrics && lyrics.length > 0 && (
|
{showLyrics && lyrics.length > 0 && (
|
||||||
<div className="flex-1 min-w-0 min-h-0 flex flex-col" ref={lyricsRef}>
|
<motion.div className="flex-1 min-w-0 min-h-0 flex flex-col" ref={lyricsRef}
|
||||||
|
initial={{ x: 30, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
exit={{ x: 30, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<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">
|
||||||
@@ -890,12 +916,15 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
23
app/components/PageTransition.tsx
Normal file
23
app/components/PageTransition.tsx
Normal file
@@ -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 (
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.div
|
||||||
|
key={pathname}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18, ease: "easeInOut" }}
|
||||||
|
className="contents"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import ThemeColorHandler from "./ThemeColorHandler";
|
|||||||
import { useViewportThemeColor } from "@/hooks/use-viewport-theme-color";
|
import { useViewportThemeColor } from "@/hooks/use-viewport-theme-color";
|
||||||
import { LoginForm } from "./start-screen";
|
import { LoginForm } from "./start-screen";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import PageTransition from "./PageTransition";
|
||||||
|
|
||||||
// Service Worker registration
|
// Service Worker registration
|
||||||
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
|
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
|
||||||
@@ -102,7 +103,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
|
|||||||
<NavidromeErrorBoundary>
|
<NavidromeErrorBoundary>
|
||||||
<AudioPlayerProvider>
|
<AudioPlayerProvider>
|
||||||
<Ihateserverside>
|
<Ihateserverside>
|
||||||
{children}
|
<PageTransition>{children}</PageTransition>
|
||||||
</Ihateserverside>
|
</Ihateserverside>
|
||||||
<WhatsNewPopup />
|
<WhatsNewPopup />
|
||||||
</AudioPlayerProvider>
|
</AudioPlayerProvider>
|
||||||
|
|||||||
@@ -72,6 +72,17 @@ export function AlbumArtwork({
|
|||||||
router.push(`/album/${album.id}`);
|
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 = () => {
|
const handleAddToQueue = () => {
|
||||||
addAlbumToQueue(album.id);
|
addAlbumToQueue(album.id);
|
||||||
};
|
};
|
||||||
@@ -129,7 +140,7 @@ export function AlbumArtwork({
|
|||||||
<div className={cn("space-y-3", className)} {...props}>
|
<div className={cn("space-y-3", className)} {...props}>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger>
|
<ContextMenuTrigger>
|
||||||
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}>
|
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()} onMouseEnter={handlePrefetch} onFocus={handlePrefetch}>
|
||||||
<div className="aspect-square relative group">
|
<div className="aspect-square relative group">
|
||||||
{album.coverArt && api && !offline.isOfflineMode ? (
|
{album.coverArt && api && !offline.isOfflineMode ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -163,7 +174,9 @@ export function AlbumArtwork({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<h3 className="font-semibold truncate">{album.name}</h3>
|
<h3 className="font-semibold truncate">
|
||||||
|
<Link href={`/album/${album.id}`} prefetch>{album.name}</Link>
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground truncate " onClick={() => router.push(album.artistId)}>{album.artist}</p>
|
<p className="text-sm text-muted-foreground truncate " onClick={() => router.push(album.artistId)}>{album.artist}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{album.songCount} songs • {Math.floor(album.duration / 60)} min
|
{album.songCount} songs • {Math.floor(album.duration / 60)} min
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { CacheManagement } from '@/app/components/CacheManagement';
|
|||||||
import { OfflineManagement } from '@/app/components/OfflineManagement';
|
import { OfflineManagement } from '@/app/components/OfflineManagement';
|
||||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog } from 'react-icons/fa';
|
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog } from 'react-icons/fa';
|
||||||
import { Settings, ExternalLink } from 'lucide-react';
|
import { Settings, ExternalLink } from 'lucide-react';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
|
||||||
const SettingsPage = () => {
|
const SettingsPage = () => {
|
||||||
const { theme, setTheme, mode, setMode } = useTheme();
|
const { theme, setTheme, mode, setMode } = useTheme();
|
||||||
@@ -59,6 +60,7 @@ const SettingsPage = () => {
|
|||||||
// Sidebar settings
|
// Sidebar settings
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [sidebarVisible, setSidebarVisible] = useState(true);
|
const [sidebarVisible, setSidebarVisible] = useState(true);
|
||||||
|
const [notifyNowPlaying, setNotifyNowPlaying] = useState(false);
|
||||||
|
|
||||||
// Initialize client-side state after hydration
|
// Initialize client-side state after hydration
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -94,6 +96,12 @@ const SettingsPage = () => {
|
|||||||
setSidebarVisible(true); // Default to visible
|
setSidebarVisible(true); // Default to visible
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notifications preference
|
||||||
|
const savedNotify = localStorage.getItem('playback-notifications-enabled');
|
||||||
|
if (savedNotify !== null) {
|
||||||
|
setNotifyNowPlaying(savedNotify === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
// Load Last.fm credentials
|
// Load Last.fm credentials
|
||||||
const storedCredentials = localStorage.getItem('lastfm-credentials');
|
const storedCredentials = localStorage.getItem('lastfm-credentials');
|
||||||
if (storedCredentials) {
|
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 = () => {
|
const handleLastFmAuth = () => {
|
||||||
if (!lastFmCredentials.apiKey) {
|
if (!lastFmCredentials.apiKey) {
|
||||||
toast({
|
toast({
|
||||||
@@ -470,6 +515,29 @@ const SettingsPage = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<Card className="mb-6 break-inside-avoid py-5">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
Notifications
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Control now playing notifications</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Now playing notifications</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Show a notification when a new song starts</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={notifyNowPlaying} onCheckedChange={handleNotifyToggle} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleTestNotification}>Test notification</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="mb-6 break-inside-avoid py-5">
|
<Card className="mb-6 break-inside-avoid py-5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"colorthief": "^2.6.0",
|
"colorthief": "^2.6.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"framer-motion": "^11.18.2",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "15.4.4",
|
"next": "15.4.4",
|
||||||
@@ -75,7 +76,6 @@
|
|||||||
"@types/react": "19.1.8",
|
"@types/react": "19.1.8",
|
||||||
"@types/react-dom": "19.1.6",
|
"@types/react-dom": "19.1.6",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"eslint": "^9.31",
|
|
||||||
"eslint": "^9.32",
|
"eslint": "^9.32",
|
||||||
"eslint-config-next": "15.4.5",
|
"eslint-config-next": "15.4.5",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
|
|||||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@@ -132,6 +132,9 @@ importers:
|
|||||||
embla-carousel-react:
|
embla-carousel-react:
|
||||||
specifier: ^8.6.0
|
specifier: ^8.6.0
|
||||||
version: 8.6.0(react@19.1.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:
|
input-otp:
|
||||||
specifier: ^1.4.2
|
specifier: ^1.4.2
|
||||||
version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
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==}
|
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||||
engines: {node: '>= 6'}
|
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:
|
function-bind@1.1.2:
|
||||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||||
|
|
||||||
@@ -2724,6 +2741,12 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
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:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@@ -5526,6 +5549,15 @@ snapshots:
|
|||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
mime-types: 2.1.35
|
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-bind@1.1.2: {}
|
||||||
|
|
||||||
function.prototype.name@1.1.8:
|
function.prototype.name@1.1.8:
|
||||||
@@ -5908,6 +5940,12 @@ snapshots:
|
|||||||
|
|
||||||
mkdirp@3.0.1: {}
|
mkdirp@3.0.1: {}
|
||||||
|
|
||||||
|
motion-dom@11.18.1:
|
||||||
|
dependencies:
|
||||||
|
motion-utils: 11.18.1
|
||||||
|
|
||||||
|
motion-utils@11.18.1: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user