feat: implement bottom navigation for mobile and enhance audio player with media session support

This commit is contained in:
2025-07-11 21:34:57 +00:00
committed by GitHub
parent 14d5036e8b
commit c101ac79eb
8 changed files with 344 additions and 208 deletions

View File

@@ -1 +1 @@
NEXT_PUBLIC_COMMIT_SHA=d8a8534 NEXT_PUBLIC_COMMIT_SHA=14d5036

View File

@@ -25,6 +25,7 @@ export const AudioPlayer: React.FC = () => {
const [isClient, setIsClient] = useState(false); const [isClient, setIsClient] = useState(false);
const [isMinimized, setIsMinimized] = useState(false); const [isMinimized, setIsMinimized] = useState(false);
const [isFullScreen, setIsFullScreen] = useState(false); const [isFullScreen, setIsFullScreen] = useState(false);
const [audioInitialized, setAudioInitialized] = useState(false);
const audioCurrent = audioRef.current; const audioCurrent = audioRef.current;
const { toast } = useToast(); const { toast } = useToast();
@@ -247,66 +248,105 @@ export const AudioPlayer: React.FC = () => {
}; };
}, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause]); }, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause]);
// Media Session API integration // Media Session API integration - Enhanced for mobile
useEffect(() => { useEffect(() => {
if (!isClient || !currentTrack || !('mediaSession' in navigator)) return; if (!isClient || !currentTrack) return;
// Check if MediaSession is supported
if (!('mediaSession' in navigator)) {
console.log('MediaSession API not supported');
return;
}
// Set metadata try {
navigator.mediaSession.metadata = new MediaMetadata({ // Set metadata
title: currentTrack.name, navigator.mediaSession.metadata = new MediaMetadata({
artist: currentTrack.artist, title: currentTrack.name,
album: currentTrack.album, artist: currentTrack.artist,
artwork: currentTrack.coverArt ? [ album: currentTrack.album,
{ src: currentTrack.coverArt, sizes: '512x512', type: 'image/jpeg' } artwork: currentTrack.coverArt ? [
] : undefined, { src: currentTrack.coverArt, sizes: '96x96', type: 'image/jpeg' },
}); { src: currentTrack.coverArt, sizes: '128x128', type: 'image/jpeg' },
{ src: currentTrack.coverArt, sizes: '192x192', type: 'image/jpeg' },
{ src: currentTrack.coverArt, sizes: '256x256', type: 'image/jpeg' },
{ src: currentTrack.coverArt, sizes: '384x384', type: 'image/jpeg' },
{ src: currentTrack.coverArt, sizes: '512x512', type: 'image/jpeg' }
] : [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' }
],
});
// Set playback state // Set playback state
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused'; navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
// Set action handlers // Set action handlers with error handling
navigator.mediaSession.setActionHandler('play', () => { navigator.mediaSession.setActionHandler('play', () => {
const audioCurrent = audioRef.current; const audioCurrent = audioRef.current;
if (audioCurrent && currentTrack) { if (audioCurrent && currentTrack) {
audioCurrent.play(); audioCurrent.play().then(() => {
setIsPlaying(true); setIsPlaying(true);
onTrackPlay(currentTrack); onTrackPlay(currentTrack);
} }).catch(console.error);
}); }
});
navigator.mediaSession.setActionHandler('pause', () => { navigator.mediaSession.setActionHandler('pause', () => {
const audioCurrent = audioRef.current; const audioCurrent = audioRef.current;
if (audioCurrent && currentTrack) { if (audioCurrent && currentTrack) {
audioCurrent.pause(); audioCurrent.pause();
setIsPlaying(false); setIsPlaying(false);
onTrackPause(audioCurrent.currentTime); onTrackPause(audioCurrent.currentTime);
} }
}); });
navigator.mediaSession.setActionHandler('previoustrack', () => { navigator.mediaSession.setActionHandler('previoustrack', () => {
playPreviousTrack(); playPreviousTrack();
}); });
navigator.mediaSession.setActionHandler('nexttrack', () => { navigator.mediaSession.setActionHandler('nexttrack', () => {
playNextTrack(); playNextTrack();
}); });
navigator.mediaSession.setActionHandler('seekto', (details) => { navigator.mediaSession.setActionHandler('seekto', (details) => {
const audioCurrent = audioRef.current; const audioCurrent = audioRef.current;
if (audioCurrent && details.seekTime !== undefined) { if (audioCurrent && details.seekTime !== undefined) {
audioCurrent.currentTime = details.seekTime; audioCurrent.currentTime = details.seekTime;
} }
}); });
return () => { // Update position state for better scrubbing support
if ('mediaSession' in navigator) { const updatePositionState = () => {
navigator.mediaSession.setActionHandler('play', null); const audioCurrent = audioRef.current;
navigator.mediaSession.setActionHandler('pause', null); if (audioCurrent && currentTrack && 'setPositionState' in navigator.mediaSession) {
navigator.mediaSession.setActionHandler('previoustrack', null); try {
navigator.mediaSession.setActionHandler('nexttrack', null); navigator.mediaSession.setPositionState({
navigator.mediaSession.setActionHandler('seekto', null); duration: audioCurrent.duration || 0,
} playbackRate: audioCurrent.playbackRate || 1.0,
}; position: audioCurrent.currentTime || 0,
});
} catch (error) {
console.log('Position state update failed:', error);
}
}
};
// Update position state periodically
const positionInterval = setInterval(updatePositionState, 1000);
return () => {
clearInterval(positionInterval);
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', null);
navigator.mediaSession.setActionHandler('pause', null);
navigator.mediaSession.setActionHandler('previoustrack', null);
navigator.mediaSession.setActionHandler('nexttrack', null);
navigator.mediaSession.setActionHandler('seekto', null);
}
};
} catch (error) {
console.error('MediaSession setup failed:', error);
}
}, [currentTrack, isPlaying, isClient, playPreviousTrack, playNextTrack, onTrackPlay, onTrackPause]); }, [currentTrack, isPlaying, isClient, playPreviousTrack, playNextTrack, onTrackPlay, onTrackPause]);
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => { const handleProgressClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
@@ -321,20 +361,54 @@ export const AudioPlayer: React.FC = () => {
} }
}; };
const togglePlayPause = () => { const togglePlayPause = async () => {
if (audioCurrent && currentTrack) { if (audioCurrent && currentTrack) {
// On mobile, ensure audio is initialized on first user interaction
if (isMobile && !audioInitialized) {
try {
// Create a dummy audio context to initialize audio on mobile
const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (AudioContextClass) {
const audioContext = new AudioContextClass();
await audioContext.resume();
setAudioInitialized(true);
}
} catch (error) {
console.log('Audio context initialization failed:', error);
}
}
if (isPlaying) { if (isPlaying) {
audioCurrent.pause(); audioCurrent.pause();
setIsPlaying(false); setIsPlaying(false);
onTrackPause(audioCurrent.currentTime); onTrackPause(audioCurrent.currentTime);
} else { } else {
audioCurrent.play().then(() => { try {
await audioCurrent.play();
setIsPlaying(true); setIsPlaying(true);
onTrackPlay(currentTrack); onTrackPlay(currentTrack);
}).catch((error) => { } catch (error) {
console.error('Failed to play audio:', error); console.error('Failed to play audio:', error);
setIsPlaying(false); // Try to initialize audio context and retry
}); if (isMobile) {
try {
const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (AudioContextClass) {
const audioContext = new AudioContextClass();
await audioContext.resume();
setAudioInitialized(true);
await audioCurrent.play();
setIsPlaying(true);
onTrackPlay(currentTrack);
}
} catch (retryError) {
console.error('Audio play retry failed:', retryError);
setIsPlaying(false);
}
} else {
setIsPlaying(false);
}
}
} }
} }
}; };
@@ -360,7 +434,7 @@ export const AudioPlayer: React.FC = () => {
if (isMobile) { if (isMobile) {
return ( return (
<> <>
<div className="fixed bottom-0 left-0 right-0 z-50 bg-background/95 backdrop-blur-sm border-t shadow-lg mobile-audio-player mobile-safe-bottom"> <div className="fixed bottom-16 left-0 right-0 z-[60] bg-background/95 backdrop-blur-sm border-t shadow-lg mobile-audio-player mobile-safe-bottom">
<div className="px-4 py-3"> <div className="px-4 py-3">
{/* Progress bar at top for mobile */} {/* Progress bar at top for mobile */}
<div className="mb-3"> <div className="mb-3">
@@ -495,7 +569,14 @@ export const AudioPlayer: React.FC = () => {
</div> </div>
{/* Single audio element - shared across all UI states */} {/* Single audio element - shared across all UI states */}
<audio ref={audioRef} hidden /> <audio
ref={audioRef}
hidden
playsInline
preload="auto"
controls={false}
crossOrigin="anonymous"
/>
<audio ref={preloadAudioRef} hidden preload="metadata" /> <audio ref={preloadAudioRef} hidden preload="metadata" />
</> </>
); );
@@ -601,8 +682,15 @@ export const AudioPlayer: React.FC = () => {
/> />
</div> </div>
{/* Single audio element - shared across all UI states */} {/* Single audio element - shared across all UI states with mobile support */}
<audio ref={audioRef} hidden /> <audio
ref={audioRef}
hidden
playsInline
preload="auto"
controls={false}
crossOrigin="anonymous"
/>
<audio ref={preloadAudioRef} hidden preload="metadata" /> <audio ref={preloadAudioRef} hidden preload="metadata" />
</> </>
); );

View File

@@ -0,0 +1,69 @@
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { Home, Search, Disc, Users, Music, Heart, List, Settings } from 'lucide-react';
import { cn } from '@/lib/utils';
interface NavItem {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
const navigationItems: NavItem[] = [
{ href: '/', label: 'Home', icon: Home },
{ href: '/search', label: 'Search', icon: Search },
{ href: '/library/albums', label: 'Albums', icon: Disc },
{ href: '/library/artists', label: 'Artists', icon: Users },
{ href: '/favorites', label: 'Favorites', icon: Heart },
{ href: '/queue', label: 'Queue', icon: List },
];
export function BottomNavigation() {
const router = useRouter();
const pathname = usePathname();
const handleNavigation = (href: string) => {
router.push(href);
};
const isActive = (href: string) => {
if (href === '/') {
return pathname === '/';
}
return pathname.startsWith(href);
};
return (
<div className="fixed bottom-0 left-0 right-0 z-[50] bg-background/95 backdrop-blur-sm border-t border-border">
<div className="flex items-center justify-around px-2 py-2 pb-safe">
{navigationItems.map((item) => {
const isItemActive = isActive(item.href);
const Icon = item.icon;
return (
<button
key={item.href}
onClick={() => handleNavigation(item.href)}
className={cn(
"flex flex-col items-center justify-center p-2 rounded-lg transition-all duration-200 min-w-[60px] touch-manipulation",
"active:scale-95 active:bg-primary/20",
isItemActive
? "text-primary bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
>
<Icon className={cn("w-5 h-5 mb-1", isItemActive && "text-primary")} />
<span className={cn(
"text-xs font-medium",
isItemActive ? "text-primary" : "text-muted-foreground"
)}>
{item.label}
</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Song } from '@/lib/navidrome'; import { Song } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext'; import { useNavidrome } from '@/app/components/NavidromeContext';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
@@ -20,11 +20,21 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]); const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [songStates, setSongStates] = useState<Record<string, boolean>>({}); const [songStates, setSongStates] = useState<Record<string, boolean>>({});
const [imageLoadingStates, setImageLoadingStates] = useState<Record<string, boolean>>({});
// Get greeting based on time of day // Memoize the greeting to prevent recalculation
const hour = new Date().getHours(); const greeting = useMemo(() => {
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'; const hour = new Date().getHours();
return hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
}, []);
// Memoized callbacks to prevent re-renders
const handleImageLoad = useCallback(() => {
// Image loaded - no state update needed to prevent re-renders
}, []);
const handleImageError = useCallback(() => {
// Image error - no state update needed to prevent re-renders
}, []);
useEffect(() => { useEffect(() => {
const loadRecommendations = async () => { const loadRecommendations = async () => {
@@ -51,15 +61,12 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
const recommendations = shuffled.slice(0, 6); const recommendations = shuffled.slice(0, 6);
setRecommendedSongs(recommendations); setRecommendedSongs(recommendations);
// Initialize starred states and image loading states // Initialize starred states only (removed image loading states)
const states: Record<string, boolean> = {}; const states: Record<string, boolean> = {};
const imageStates: Record<string, boolean> = {};
recommendations.forEach((song: Song) => { recommendations.forEach((song: Song) => {
states[song.id] = !!song.starred; states[song.id] = !!song.starred;
imageStates[song.id] = true; // Start with loading state
}); });
setSongStates(states); setSongStates(states);
setImageLoadingStates(imageStates);
} catch (error) { } catch (error) {
console.error('Failed to load song recommendations:', error); console.error('Failed to load song recommendations:', error);
} finally { } finally {
@@ -159,33 +166,25 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0"> <div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0">
{song.coverArt && api ? ( {song.coverArt && api ? (
<> <>
{imageLoadingStates[song.id] && (
<div className="absolute inset-0 bg-muted flex items-center justify-center">
<Music className="w-6 h-6 text-muted-foreground animate-pulse" />
</div>
)}
<Image <Image
src={api.getCoverArtUrl(song.coverArt, 100)} src={api.getCoverArtUrl(song.coverArt, 100)}
alt={song.title} alt={song.title}
fill fill
className={`object-cover transition-opacity duration-300 ${ className="object-cover"
imageLoadingStates[song.id] ? 'opacity-0' : 'opacity-100'
}`}
sizes="48px" sizes="48px"
onLoad={() => setImageLoadingStates(prev => ({ ...prev, [song.id]: false }))} onLoad={handleImageLoad}
onError={() => setImageLoadingStates(prev => ({ ...prev, [song.id]: false }))} 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"> <div className="w-full h-full flex items-center justify-center">
<Music className="w-6 h-6 text-muted-foreground" /> <Music className="w-6 h-6 text-muted-foreground" />
</div> </div>
)} )}
{!imageLoadingStates[song.id] && (
<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> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">

View File

@@ -20,7 +20,7 @@ import { useNavidrome } from "./NavidromeContext"
import Link from "next/link"; import Link from "next/link";
import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext"; import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext";
import { getNavidromeAPI } from "@/lib/navidrome"; import { getNavidromeAPI } from "@/lib/navidrome";
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ArtistIcon } from "@/app/components/artist-icon"; import { ArtistIcon } from "@/app/components/artist-icon";
@@ -46,8 +46,21 @@ export function AlbumArtwork({
const router = useRouter(); const router = useRouter();
const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer(); const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer();
const { playlists, starItem, unstarItem } = useNavidrome(); const { playlists, starItem, unstarItem } = useNavidrome();
const [imageLoading, setImageLoading] = useState(true);
const [imageError, setImageError] = useState(false); // Memoize cover art URL to prevent recalculation on every render
const coverArtUrl = useMemo(() => {
if (!api || !album.coverArt) return '/default-user.jpg';
return api.getCoverArtUrl(album.coverArt);
}, [api, album.coverArt]);
// Use callback to prevent function recreation on every render
const handleImageLoad = useCallback(() => {
// Image loaded successfully - no state update needed
}, []);
const handleImageError = useCallback(() => {
// Image failed to load - could set error state if needed
}, []);
const handleClick = () => { const handleClick = () => {
router.push(`/album/${album.id}`); router.push(`/album/${album.id}`);
@@ -105,68 +118,42 @@ export function AlbumArtwork({
console.error('Failed to toggle favorite:', error); console.error('Failed to toggle favorite:', error);
} }
}; };
// Get cover art URL with proper fallback
const coverArtUrl = album.coverArt && api
? api.getCoverArtUrl(album.coverArt, 300)
: '/default-user.jpg';
return ( return (
<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()}>
<div className="aspect-square relative group"> <div className="aspect-square relative group">
{album.coverArt && api ? ( {album.coverArt && api ? (
<> <Image
{imageLoading && ( src={coverArtUrl}
<div className="absolute inset-0 bg-muted animate-pulse rounded flex items-center justify-center"> alt={album.name}
<Disc className="w-12 h-12 text-muted-foreground animate-spin" /> fill
</div> className="w-full h-full object-cover transition-all"
)} sizes="(max-width: 768px) 100vw, 300px"
<Image onLoad={handleImageLoad}
src={api.getCoverArtUrl(album.coverArt)} onError={handleImageError}
alt={album.name} priority={false}
fill loading="lazy"
className={`w-full h-full object-cover transition-opacity duration-300 ${ />
imageLoading ? 'opacity-0' : 'opacity-100' ) : (
}`} <div className="w-full h-full bg-muted rounded flex items-center justify-center">
sizes="(max-width: 768px) 100vw, 300px" <Disc className="w-12 h-12 text-muted-foreground" />
onLoad={() => setImageLoading(false)} </div>
onError={() => { )}
setImageLoading(false); <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
setImageError(true); <Play className="w-6 h-6 mx-auto hidden group-hover:block" onClick={() => handlePlayAlbum(album)}/>
}} </div>
/> </div>
</> <CardContent className="p-4">
) : ( <h3 className="font-semibold truncate">{album.name}</h3>
<div className="w-full h-full bg-muted rounded flex items-center justify-center"> <p className="text-sm text-muted-foreground truncate " onClick={() => router.push(album.artistId)}>{album.artist}</p>
<Disc className="w-12 h-12 text-muted-foreground" /> <p className="text-xs text-muted-foreground mt-1">
</div> {album.songCount} songs {Math.floor(album.duration / 60)} min
)} </p>
{!imageLoading && ( </CardContent>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2"> </Card>
<Play className="w-6 h-6 mx-auto hidden group-hover:block" onClick={() => handlePlayAlbum(album)}/>
</div>
)}
</div>
<CardContent className="p-4">
{imageLoading ? (
<>
<div className="h-5 w-3/4 bg-muted animate-pulse rounded mb-2" />
<div className="h-4 w-1/2 bg-muted animate-pulse rounded mb-1" />
<div className="h-3 w-2/3 bg-muted animate-pulse rounded" />
</>
) : (
<>
<h3 className="font-semibold truncate">{album.name}</h3>
<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">
{album.songCount} songs {Math.floor(album.duration / 60)} min
</p>
</>
)}
</CardContent>
</Card>
{/* <div onClick={handleClick} className="overflow-hidden rounded-md"> {/* <div onClick={handleClick} className="overflow-hidden rounded-md">
<Image <Image
src={coverArtUrl} src={coverArtUrl}

View File

@@ -5,6 +5,7 @@ import { Menu } from "@/app/components/menu";
import { Sidebar } from "@/app/components/sidebar"; import { Sidebar } from "@/app/components/sidebar";
import { useNavidrome } from "@/app/components/NavidromeContext"; import { useNavidrome } from "@/app/components/NavidromeContext";
import { AudioPlayer } from "./AudioPlayer"; import { AudioPlayer } from "./AudioPlayer";
import { BottomNavigation } from './BottomNavigation';
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { useFavoriteAlbums } from "@/hooks/use-favorite-albums"; import { useFavoriteAlbums } from "@/hooks/use-favorite-albums";
@@ -111,12 +112,14 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
/> />
</div> </div>
{/* Main Content Area with bottom padding for audio player */} {/* Main Content Area with bottom padding for audio player and bottom nav */}
<div className="flex-1 overflow-y-auto pb-24"> <div className="flex-1 overflow-y-auto pb-40">
<div>{children}</div> <div>{children}</div>
</div> </div>
{/* Mobile Audio Player - always visible on mobile */} {/* Bottom Navigation for Mobile */}
<BottomNavigation />
<Toaster /> <Toaster />
</div> </div>

View File

@@ -156,66 +156,10 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
return ( return (
<> <>
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
{/* Mobile Navigation */} {/* Mobile Top Bar - Simplified since navigation is now at bottom */}
{isMobile ? ( {isMobile ? (
<div className="flex items-center justify-between w-full p-2"> <div className="flex items-center justify-center w-full p-2">
<div className="flex items-center gap-2"> <h1 className="font-bold text-lg">mice</h1>
<Drawer open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<DrawerTrigger asChild>
<Button variant="ghost" size="sm" className="p-2">
<MenuIcon className="h-5 w-5" />
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle className="flex items-center gap-2">
<Image src="/icon-192.png" alt="mice" width={24} height={24} className="rounded" />
mice
</DrawerTitle>
<DrawerDescription>
Navigate through your music library
</DrawerDescription>
</DrawerHeader>
<div className="px-4 pb-6">
<div className="grid gap-2">
{navigationItems.map((item) => (
<Link key={item.href} href={item.href}>
<DrawerClose asChild>
<Button
variant="ghost"
className="w-full justify-start gap-3 h-12"
onClick={() => setMobileMenuOpen(false)}
>
<item.icon className="h-5 w-5" />
{item.label}
</Button>
</DrawerClose>
</Link>
))}
</div>
</div>
</DrawerContent>
</Drawer>
<h1 className="font-bold text-lg">mice</h1>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => router.push('/search')}
className="p-2"
>
<Search className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setOpen(true)}
className="p-2"
>
<Settings className="h-5 w-5" />
</Button>
</div>
</div> </div>
) : ( ) : (
/* Desktop Navigation */ /* Desktop Navigation */

View File

@@ -853,6 +853,34 @@
} }
} }
/* Safe area support for mobile devices */
.pb-safe {
padding-bottom: env(safe-area-inset-bottom, 0.5rem);
}
.mobile-safe-bottom {
margin-bottom: env(safe-area-inset-bottom, 0);
}
/* Touch-optimized navigation */
.touch-manipulation {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
/* Bottom navigation z-index fix */
.bottom-nav {
z-index: 45;
}
/* Audio player above bottom nav */
.mobile-audio-above-nav {
z-index: 50;
bottom: calc(4rem + env(safe-area-inset-bottom, 0));
}
/* Better focus states for accessibility */ /* Better focus states for accessibility */
button:focus-visible { button:focus-visible {
outline: 2px solid hsl(var(--primary)); outline: 2px solid hsl(var(--primary));
@@ -908,4 +936,22 @@ button:focus-visible {
/* /*
---break--- ---break---
*/ */
/* Mobile Bottom Navigation Styles */
.pb-safe {
padding-bottom: env(safe-area-inset-bottom, 0.5rem);
}
.mobile-safe-bottom {
margin-bottom: env(safe-area-inset-bottom, 0);
}
.touch-manipulation {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.mobile-audio-above-nav {
bottom: calc(4rem + env(safe-area-inset-bottom, 0));
}