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

@@ -25,6 +25,7 @@ export const AudioPlayer: React.FC = () => {
const [isClient, setIsClient] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [isFullScreen, setIsFullScreen] = useState(false);
const [audioInitialized, setAudioInitialized] = useState(false);
const audioCurrent = audioRef.current;
const { toast } = useToast();
@@ -247,66 +248,105 @@ export const AudioPlayer: React.FC = () => {
};
}, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause]);
// Media Session API integration
// Media Session API integration - Enhanced for mobile
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
navigator.mediaSession.metadata = new MediaMetadata({
title: currentTrack.name,
artist: currentTrack.artist,
album: currentTrack.album,
artwork: currentTrack.coverArt ? [
{ src: currentTrack.coverArt, sizes: '512x512', type: 'image/jpeg' }
] : undefined,
});
try {
// Set metadata
navigator.mediaSession.metadata = new MediaMetadata({
title: currentTrack.name,
artist: currentTrack.artist,
album: currentTrack.album,
artwork: currentTrack.coverArt ? [
{ 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
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
// Set playback state
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
// Set action handlers
navigator.mediaSession.setActionHandler('play', () => {
const audioCurrent = audioRef.current;
if (audioCurrent && currentTrack) {
audioCurrent.play();
setIsPlaying(true);
onTrackPlay(currentTrack);
}
});
// Set action handlers with error handling
navigator.mediaSession.setActionHandler('play', () => {
const audioCurrent = audioRef.current;
if (audioCurrent && currentTrack) {
audioCurrent.play().then(() => {
setIsPlaying(true);
onTrackPlay(currentTrack);
}).catch(console.error);
}
});
navigator.mediaSession.setActionHandler('pause', () => {
const audioCurrent = audioRef.current;
if (audioCurrent && currentTrack) {
audioCurrent.pause();
setIsPlaying(false);
onTrackPause(audioCurrent.currentTime);
}
});
navigator.mediaSession.setActionHandler('pause', () => {
const audioCurrent = audioRef.current;
if (audioCurrent && currentTrack) {
audioCurrent.pause();
setIsPlaying(false);
onTrackPause(audioCurrent.currentTime);
}
});
navigator.mediaSession.setActionHandler('previoustrack', () => {
playPreviousTrack();
});
navigator.mediaSession.setActionHandler('previoustrack', () => {
playPreviousTrack();
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
playNextTrack();
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
playNextTrack();
});
navigator.mediaSession.setActionHandler('seekto', (details) => {
const audioCurrent = audioRef.current;
if (audioCurrent && details.seekTime !== undefined) {
audioCurrent.currentTime = details.seekTime;
}
});
navigator.mediaSession.setActionHandler('seekto', (details) => {
const audioCurrent = audioRef.current;
if (audioCurrent && details.seekTime !== undefined) {
audioCurrent.currentTime = details.seekTime;
}
});
return () => {
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);
}
};
// Update position state for better scrubbing support
const updatePositionState = () => {
const audioCurrent = audioRef.current;
if (audioCurrent && currentTrack && 'setPositionState' in navigator.mediaSession) {
try {
navigator.mediaSession.setPositionState({
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]);
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
@@ -321,20 +361,54 @@ export const AudioPlayer: React.FC = () => {
}
};
const togglePlayPause = () => {
const togglePlayPause = async () => {
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) {
audioCurrent.pause();
setIsPlaying(false);
onTrackPause(audioCurrent.currentTime);
} else {
audioCurrent.play().then(() => {
try {
await audioCurrent.play();
setIsPlaying(true);
onTrackPlay(currentTrack);
}).catch((error) => {
} catch (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) {
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">
{/* Progress bar at top for mobile */}
<div className="mb-3">
@@ -495,7 +569,14 @@ export const AudioPlayer: React.FC = () => {
</div>
{/* 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" />
</>
);
@@ -601,8 +682,15 @@ export const AudioPlayer: React.FC = () => {
/>
</div>
{/* Single audio element - shared across all UI states */}
<audio ref={audioRef} hidden />
{/* Single audio element - shared across all UI states with mobile support */}
<audio
ref={audioRef}
hidden
playsInline
preload="auto"
controls={false}
crossOrigin="anonymous"
/>
<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';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Song } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
@@ -20,11 +20,21 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
const [loading, setLoading] = useState(true);
const [songStates, setSongStates] = useState<Record<string, boolean>>({});
const [imageLoadingStates, setImageLoadingStates] = useState<Record<string, boolean>>({});
// Get greeting based on time of day
const hour = new Date().getHours();
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
// Memoize the greeting to prevent recalculation
const greeting = useMemo(() => {
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(() => {
const loadRecommendations = async () => {
@@ -51,15 +61,12 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
const recommendations = shuffled.slice(0, 6);
setRecommendedSongs(recommendations);
// Initialize starred states and image loading states
// Initialize starred states only (removed image loading states)
const states: Record<string, boolean> = {};
const imageStates: Record<string, boolean> = {};
recommendations.forEach((song: Song) => {
states[song.id] = !!song.starred;
imageStates[song.id] = true; // Start with loading state
});
setSongStates(states);
setImageLoadingStates(imageStates);
} catch (error) {
console.error('Failed to load song recommendations:', error);
} 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">
{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
src={api.getCoverArtUrl(song.coverArt, 100)}
alt={song.title}
fill
className={`object-cover transition-opacity duration-300 ${
imageLoadingStates[song.id] ? 'opacity-0' : 'opacity-100'
}`}
className="object-cover"
sizes="48px"
onLoad={() => setImageLoadingStates(prev => ({ ...prev, [song.id]: false }))}
onError={() => setImageLoadingStates(prev => ({ ...prev, [song.id]: false }))}
onLoad={handleImageLoad}
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">
<Music className="w-6 h-6 text-muted-foreground" />
</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 className="flex-1 min-w-0">

View File

@@ -20,7 +20,7 @@ import { useNavidrome } from "./NavidromeContext"
import Link from "next/link";
import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext";
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ArtistIcon } from "@/app/components/artist-icon";
@@ -46,8 +46,21 @@ export function AlbumArtwork({
const router = useRouter();
const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer();
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 = () => {
router.push(`/album/${album.id}`);
@@ -105,68 +118,42 @@ export function AlbumArtwork({
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 (
<div className={cn("space-y-3", className)} {...props}>
<ContextMenu>
<ContextMenuTrigger>
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}>
<div className="aspect-square relative group">
{album.coverArt && api ? (
<>
{imageLoading && (
<div className="absolute inset-0 bg-muted animate-pulse rounded flex items-center justify-center">
<Disc className="w-12 h-12 text-muted-foreground animate-spin" />
</div>
)}
<Image
src={api.getCoverArtUrl(album.coverArt)}
alt={album.name}
fill
className={`w-full h-full object-cover transition-opacity duration-300 ${
imageLoading ? 'opacity-0' : 'opacity-100'
}`}
sizes="(max-width: 768px) 100vw, 300px"
onLoad={() => setImageLoading(false)}
onError={() => {
setImageLoading(false);
setImageError(true);
}}
/>
</>
) : (
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
<Disc className="w-12 h-12 text-muted-foreground" />
</div>
)}
{!imageLoading && (
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<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 className="aspect-square relative group">
{album.coverArt && api ? (
<Image
src={coverArtUrl}
alt={album.name}
fill
className="w-full h-full object-cover transition-all"
sizes="(max-width: 768px) 100vw, 300px"
onLoad={handleImageLoad}
onError={handleImageError}
priority={false}
loading="lazy"
/>
) : (
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
<Disc className="w-12 h-12 text-muted-foreground" />
</div>
)}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<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>
<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">
<Image
src={coverArtUrl}

View File

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

View File

@@ -156,66 +156,10 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
return (
<>
<div className="flex items-center justify-between w-full">
{/* Mobile Navigation */}
{/* Mobile Top Bar - Simplified since navigation is now at bottom */}
{isMobile ? (
<div className="flex items-center justify-between w-full p-2">
<div className="flex items-center gap-2">
<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 className="flex items-center justify-center w-full p-2">
<h1 className="font-bold text-lg">mice</h1>
</div>
) : (
/* 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 */
button:focus-visible {
outline: 2px solid hsl(var(--primary));
@@ -908,4 +936,22 @@ button:focus-visible {
/*
---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));
}