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 [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" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
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';
|
'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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user