feat: implement bottom navigation for mobile and enhance audio player with media session support
This commit is contained in:
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_COMMIT_SHA=d8a8534
|
||||
NEXT_PUBLIC_COMMIT_SHA=14d5036
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
|
||||
69
app/components/BottomNavigation.tsx
Normal file
69
app/components/BottomNavigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user