Merge pull request #31 from sillyangel/mobile-support

YAY
This commit was merged in pull request #31.
This commit is contained in:
2025-08-01 13:30:28 -05:00
committed by GitHub
48 changed files with 3570 additions and 1258 deletions

View File

@@ -1 +1 @@
NEXT_PUBLIC_COMMIT_SHA=35febc5
NEXT_PUBLIC_COMMIT_SHA=0c32c05

38
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,38 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug: Next.js Development",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/.bin/next",
"args": ["dev"],
"console": "integratedTerminal",
"env": {
"NODE_ENV": "development"
},
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "dev"],
"skipFiles": ["<node_internals>/**"],
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
]
},
{
"name": "Debug: Next.js Production",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/.bin/next",
"args": ["start"],
"console": "integratedTerminal",
"env": {
"NODE_ENV": "production"
},
"preLaunchTask": "Build: Production Build Only",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "start"],
"skipFiles": ["<node_internals>/**"]
}
]
}

114
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,114 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Dev: Start Development Server",
"type": "shell",
"command": "pnpm",
"args": [
"run",
"dev"
],
"group": {
"kind": "build",
"isDefault": true
},
"isBackground": true,
"problemMatcher": [
"$tsc-watch"
],
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new",
"showReuseMessage": true,
"clear": false
},
"options": {
"env": {
"NODE_ENV": "development"
}
}
},
{
"label": "Prod: Build and Start Production",
"type": "shell",
"command": "bash",
"args": [
"-c",
"pnpm run build && pnpm run start"
],
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "new",
"showReuseMessage": true,
"clear": true
},
"options": {
"env": {
"NODE_ENV": "production"
}
},
"problemMatcher": ["$tsc"],
"dependsOrder": "sequence"
},
{
"label": "Debug: Development with Debug Info",
"type": "shell",
"command": "pnpm",
"args": [
"run",
"dev"
],
"group": {
"kind": "test",
"isDefault": false
},
"isBackground": true,
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new",
"showReuseMessage": true,
"clear": false
},
"options": {
"env": {
"NODE_ENV": "development",
"DEBUG": "*",
"NEXT_TELEMETRY_DISABLED": "1"
}
},
"problemMatcher": ["$tsc-watch"]
},
{
"label": "Build: Production Build Only",
"type": "shell",
"command": "pnpm",
"args": [
"run",
"build"
],
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "new",
"showReuseMessage": true,
"clear": true
},
"options": {
"env": {
"NODE_ENV": "production"
}
},
"problemMatcher": ["$tsc"]
}
]
}

View File

@@ -10,9 +10,9 @@ import Link from 'next/link';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'
import Loading from "@/app/components/loading";
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { getNavidromeAPI } from '@/lib/navidrome';
import { useFavoriteAlbums } from '@/hooks/use-favorite-albums';
import { useIsMobile } from '@/hooks/use-mobile';
export default function AlbumPage() {
const { id } = useParams();
@@ -24,6 +24,7 @@ export default function AlbumPage() {
const { getAlbum, starItem, unstarItem } = useNavidrome();
const { playTrack, addAlbumToQueue, playAlbum, playAlbumFromTrack, currentTrack } = useAudioPlayer();
const { isFavoriteAlbum, toggleFavoriteAlbum } = useFavoriteAlbums();
const isMobile = useIsMobile();
const api = getNavidromeAPI();
useEffect(() => {
@@ -119,18 +120,67 @@ export default function AlbumPage() {
const seconds = duration % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
// Get cover art URL with proper fallback
const coverArtUrl = album.coverArt && api
// Dynamic cover art URLs based on image size
const getMobileCoverArtUrl = () => {
return album.coverArt && api
? api.getCoverArtUrl(album.coverArt, 280)
: '/default-user.jpg';
};
const getDesktopCoverArtUrl = () => {
return album.coverArt && api
? api.getCoverArtUrl(album.coverArt, 300)
: '/default-user.jpg';
};
return (
<>
<div className="h-full px-4 py-6 lg:px-8">
<div className="space-y-4">
{isMobile ? (
/* Mobile Layout */
<div className="space-y-6">
{/* Album Cover - Centered */}
<div className="flex justify-center">
<Image
src={getMobileCoverArtUrl()}
alt={album.name}
width={280}
height={280}
className="rounded-md shadow-lg"
/>
</div>
{/* Album Info and Controls */}
<div className="flex justify-between items-start gap-4">
{/* Left side - Album Info */}
<div className="flex-1 space-y-1">
<h1 className="text-2xl font-bold text-left">{album.name}</h1>
<Link href={`/artist/${album.artistId}`}>
<p className="text-lg text-primary underline text-left">{album.artist}</p>
</Link>
<p className="text-sm text-muted-foreground text-left">{album.genre} {album.year}</p>
<p className="text-sm text-muted-foreground text-left">{album.songCount} songs, {formatDuration(album.duration)}</p>
</div>
{/* Right side - Controls */}
<div className="flex flex-col items-center gap-3">
<Button
className="w-12 h-12 rounded-full p-0"
onClick={() => playAlbum(album.id)}
title="Play Album"
>
<Play className="w-6 h-6" />
</Button>
</div>
</div>
</div>
) : (
/* Desktop Layout */
<div className="flex items-start gap-6">
<Image
src={coverArtUrl}
src={getDesktopCoverArtUrl()}
alt={album.name}
width={300}
height={300}
@@ -152,20 +202,19 @@ export default function AlbumPage() {
<div className="text-sm text-muted-foreground">
<p>{album.genre} {album.year}</p>
<p>{album.songCount} songs, {formatDuration(album.duration)}</p>
</div>
</div>
</div>
)}
<div className="space-y-4">
<Separator />
<ScrollArea className="h-[calc(100vh-500px)]">
{tracklist.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">No tracks available.</p>
</div>
) : (
<div className="space-y-1">
<div className="space-y-1 pb-32">
{tracklist.map((song, index) => (
<div
key={song.id}
@@ -222,7 +271,6 @@ export default function AlbumPage() {
))}
</div>
)}
</ScrollArea>
</div>
</div>
</div>

View File

@@ -15,6 +15,7 @@ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import Loading from '@/app/components/loading';
import { getNavidromeAPI } from '@/lib/navidrome';
import { useToast } from '@/hooks/use-toast';
import { useIsMobile } from '@/hooks/use-mobile';
export default function ArtistPage() {
const { artist: artistId } = useParams();
@@ -27,6 +28,7 @@ export default function ArtistPage() {
const { getArtist, starItem, unstarItem } = useNavidrome();
const { playArtist } = useAudioPlayer();
const { toast } = useToast();
const isMobile = useIsMobile();
const api = getNavidromeAPI();
useEffect(() => {
@@ -103,7 +105,7 @@ export default function ArtistPage() {
}
// Get artist image URL with proper fallback
const artistImageUrl = artist.coverArt && api
? api.getCoverArtUrl(artist.coverArt, 300)
? api.getCoverArtUrl(artist.coverArt, 1200)
: '/default-user.jpg';
return (
@@ -152,7 +154,7 @@ export default function ArtistPage() {
<ArtistBio artistName={artist.name} />
{/* Popular Songs Section */}
{popularSongs.length > 0 && (
{!isMobile && popularSongs.length > 0 && (
<PopularSongs songs={popularSongs} artistName={artist.name} />
)}

View File

@@ -10,10 +10,19 @@ import { Progress } from '@/components/ui/progress';
import { useToast } from '@/hooks/use-toast';
import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler';
import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
import { useIsMobile } from '@/hooks/use-mobile';
export const AudioPlayer: React.FC = () => {
const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue, queue, toggleShuffle, shuffle, toggleCurrentTrackStar } = useAudioPlayer();
const router = useRouter();
const isMobile = useIsMobile();
// Swipe gesture state for mobile
const [touchStart, setTouchStart] = useState<number | null>(null);
const [touchEnd, setTouchEnd] = useState<number | null>(null);
// Minimum swipe distance (in px)
const minSwipeDistance = 50;
const audioRef = useRef<HTMLAudioElement>(null);
const preloadAudioRef = useRef<HTMLAudioElement>(null);
const [progress, setProgress] = useState(0);
@@ -23,9 +32,36 @@ 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();
// Swipe gesture handlers for mobile
const handleTouchStart = (e: React.TouchEvent) => {
setTouchEnd(null);
setTouchStart(e.targetTouches[0].clientX);
};
const handleTouchMove = (e: React.TouchEvent) => {
setTouchEnd(e.targetTouches[0].clientX);
};
const handleTouchEnd = () => {
if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const isLeftSwipe = distance > minSwipeDistance;
const isRightSwipe = distance < -minSwipeDistance;
if (isLeftSwipe) {
// Swipe left -> next track
playNextTrack();
} else if (isRightSwipe) {
// Swipe right -> previous track
playPreviousTrack();
}
};
// Last.fm scrobbler integration (Navidrome)
const {
onTrackStart: navidromeOnTrackStart,
@@ -91,6 +127,89 @@ export const AudioPlayer: React.FC = () => {
}
}
// Mobile-specific audio initialization
if (isMobile) {
// Detect if running as PWA
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
console.log('🔍 Audio initialization debug:', {
isMobile,
isPWA,
audioInitialized,
userAgent: navigator.userAgent
});
// Add a document click listener to initialize audio context on first user interaction
const initializeAudioOnMobile = async () => {
if (!audioInitialized) {
try {
console.log('🎵 Initializing mobile audio context...', { isPWA });
const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (AudioContextClass) {
const audioContext = new AudioContextClass();
console.log('Audio context state:', audioContext.state);
if (audioContext.state === 'suspended') {
console.log('Resuming suspended audio context...');
await audioContext.resume();
console.log('Audio context resumed, new state:', audioContext.state);
}
// For PWA, we need to explicitly unlock audio
if (isPWA && audioRef.current) {
console.log('PWA detected, performing audio unlock...');
// Create a silent audio buffer to unlock audio
const buffer = audioContext.createBuffer(1, 1, 22050);
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
source.start(0);
// Also try to load the audio element
try {
audioRef.current.volume = 0;
const playPromise = audioRef.current.play();
if (playPromise) {
await playPromise;
audioRef.current.pause();
audioRef.current.currentTime = 0;
}
audioRef.current.volume = volume;
console.log('✅ PWA audio unlock successful');
} catch (unlockError) {
console.log('⚠️ PWA audio unlock failed:', unlockError);
}
}
setAudioInitialized(true);
console.log('✅ Mobile audio context initialized successfully');
}
} catch (error) {
console.log('❌ Mobile audio context initialization failed:', error);
}
}
};
// Listen for any user interaction to initialize audio
const handleFirstUserInteraction = () => {
console.log('🎯 First user interaction detected, initializing audio...');
initializeAudioOnMobile();
document.removeEventListener('touchstart', handleFirstUserInteraction);
document.removeEventListener('click', handleFirstUserInteraction);
};
document.addEventListener('touchstart', handleFirstUserInteraction, { passive: true });
document.addEventListener('click', handleFirstUserInteraction);
return () => {
document.removeEventListener('touchstart', handleFirstUserInteraction);
document.removeEventListener('click', handleFirstUserInteraction);
};
}
// Clean up old localStorage entries with track IDs
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
@@ -100,7 +219,7 @@ export const AudioPlayer: React.FC = () => {
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
}, []);
}, [isMobile, audioInitialized, volume]);
// Apply volume to audio element when volume changes
useEffect(() => {
@@ -129,8 +248,76 @@ export const AudioPlayer: React.FC = () => {
// Always clear current track time when changing tracks
localStorage.removeItem('navidrome-current-track-time');
console.log('🔄 Setting audio source:', currentTrack.url);
// Debug: Check if URL is valid
if (!currentTrack.url || currentTrack.url === 'undefined' || currentTrack.url === '') {
console.error('❌ Invalid audio URL:', currentTrack.url);
return;
}
// Debug: Log current audio element state
console.log('🔍 Audio element state before loading:', {
src: audioCurrent.src,
readyState: audioCurrent.readyState,
networkState: audioCurrent.networkState,
crossOrigin: audioCurrent.crossOrigin,
canPlayType_mp3: audioCurrent.canPlayType('audio/mpeg'),
canPlayType_mp4: audioCurrent.canPlayType('audio/mp4'),
canPlayType_webm: audioCurrent.canPlayType('audio/webm'),
canPlayType_ogg: audioCurrent.canPlayType('audio/ogg'),
canPlayType_flac: audioCurrent.canPlayType('audio/flac'),
canPlayType_wav: audioCurrent.canPlayType('audio/wav')
});
// Clear any previous error handlers
audioCurrent.onerror = null;
audioCurrent.onloadstart = null;
audioCurrent.oncanplay = null;
// Simple error handling
audioCurrent.onerror = (e) => {
const event = e as Event;
const error = event.target as HTMLAudioElement;
console.error('❌ Audio element error:', {
error: error.error,
networkState: error.networkState,
readyState: error.readyState,
src: error.src
});
};
audioCurrent.onloadstart = () => {
console.log('📥 Audio load started');
};
audioCurrent.oncanplay = () => {
console.log('✅ Audio can play');
};
// Set source without any CORS configuration
audioCurrent.removeAttribute('crossorigin');
audioCurrent.src = currentTrack.url;
// Force load and log state after setting source
audioCurrent.load();
// Log state after load
setTimeout(() => {
console.log('🔍 Audio element state after load:', {
src: audioCurrent.src,
readyState: audioCurrent.readyState,
networkState: audioCurrent.networkState,
error: audioCurrent.error,
duration: audioCurrent.duration
});
}, 100);
// For iOS, ensure audio element is properly loaded
if (isMobile) {
audioCurrent.load();
}
// Notify scrobbler about new track
onTrackStart(currentTrack);
@@ -157,21 +344,31 @@ export const AudioPlayer: React.FC = () => {
localStorage.removeItem('navidrome-current-track-time');
}
// Auto-play only if the track has the autoPlay flag
if (currentTrack.autoPlay) {
audioCurrent.play().then(() => {
// Auto-play only if the track has the autoPlay flag and audio is initialized
if (currentTrack.autoPlay && (!isMobile || audioInitialized)) {
// Add a small delay for iOS compatibility
const playPromise = isMobile ?
new Promise(resolve => setTimeout(resolve, 100)).then(() => audioCurrent.play()) :
audioCurrent.play();
playPromise.then(() => {
setIsPlaying(true);
// Notify scrobbler about play
onTrackPlay(currentTrack);
}).catch((error) => {
console.error('Failed to auto-play:', error);
setIsPlaying(false);
// On iOS, auto-play might fail - that's normal
if (isMobile) {
console.log('Auto-play failed on mobile - user interaction required');
}
});
} else {
setIsPlaying(false);
}
}
}, [currentTrack, onTrackStart, onTrackPlay]);
}, [currentTrack, onTrackStart, onTrackPlay, isMobile, audioInitialized]);
useEffect(() => {
const audioCurrent = audioRef.current;
@@ -245,30 +442,46 @@ 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;
}
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' }
] : undefined,
] : [
{ 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 action handlers
// Set action handlers with error handling
navigator.mediaSession.setActionHandler('play', () => {
const audioCurrent = audioRef.current;
if (audioCurrent && currentTrack) {
audioCurrent.play();
audioCurrent.play().then(() => {
setIsPlaying(true);
onTrackPlay(currentTrack);
}).catch(console.error);
}
});
@@ -296,18 +509,64 @@ export const AudioPlayer: React.FC = () => {
}
});
// Add togglefavorite action for iOS
try {
// togglefavorite is an iOS-specific action that may not be in TypeScript definitions
const mediaSession = navigator.mediaSession as MediaSession & {
setActionHandler(action: 'togglefavorite', handler: MediaSessionActionHandler | null): void;
};
mediaSession.setActionHandler('togglefavorite', () => {
toggleCurrentTrackStar();
});
} catch (error) {
// togglefavorite might not be supported on all platforms
console.log('togglefavorite action not supported:', error);
}
// 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);
try {
const mediaSession = navigator.mediaSession as MediaSession & {
setActionHandler(action: 'togglefavorite', handler: MediaSessionActionHandler | null): void;
};
mediaSession.setActionHandler('togglefavorite', null);
} catch (error) {
// togglefavorite might not be supported
}
}
};
}, [currentTrack, isPlaying, isClient, playPreviousTrack, playNextTrack, onTrackPlay, onTrackPause]);
} catch (error) {
console.error('MediaSession setup failed:', error);
}
}, [currentTrack, isPlaying, isClient, playPreviousTrack, playNextTrack, onTrackPlay, onTrackPause, toggleCurrentTrackStar]);
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.stopPropagation(); // Prevent triggering fullscreen
if (audioCurrent && currentTrack) {
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
@@ -319,21 +578,136 @@ export const AudioPlayer: React.FC = () => {
}
};
const togglePlayPause = () => {
const togglePlayPause = async () => {
if (audioCurrent && currentTrack) {
// Detect if running as PWA
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
console.log('🎵 togglePlayPause called:', {
isPlaying,
isMobile,
isPWA,
audioInitialized,
currentTrackUrl: currentTrack.url,
audioSrc: audioCurrent.src,
audioReadyState: audioCurrent.readyState
});
if (isPlaying) {
console.log('⏸️ Pausing audio');
audioCurrent.pause();
setIsPlaying(false);
onTrackPause(audioCurrent.currentTime);
} else {
audioCurrent.play().then(() => {
try {
// PWA-specific initialization if needed
if (isPWA && !audioInitialized) {
console.log('🔧 PWA detected - initializing audio context...');
try {
const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (AudioContextClass) {
const audioContext = new AudioContextClass();
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
setAudioInitialized(true);
console.log('✅ PWA audio context initialized');
}
} catch (contextError) {
console.log('⚠️ PWA audio context initialization failed:', contextError);
}
}
// On mobile, ensure audio element is properly loaded before playing
if (isMobile) {
// Ensure the audio element has the correct source
if (audioCurrent.src !== currentTrack.url) {
console.log('🔄 Setting audio source:', currentTrack.url);
audioCurrent.src = currentTrack.url;
audioCurrent.load(); // Force reload the audio element
}
// Wait for the audio to be ready to play
if (audioCurrent.readyState < 3) { // HAVE_FUTURE_DATA
console.log('⏳ Waiting for audio to be ready...');
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
audioCurrent.removeEventListener('canplay', handleCanPlay);
audioCurrent.removeEventListener('error', handleError);
reject(new Error('Audio load timeout'));
}, 10000); // 10 second timeout
const handleCanPlay = () => {
console.log('✅ Audio ready to play');
clearTimeout(timeout);
audioCurrent.removeEventListener('canplay', handleCanPlay);
audioCurrent.removeEventListener('error', handleError);
resolve(void 0);
};
const handleError = () => {
console.log('❌ Audio load error');
clearTimeout(timeout);
audioCurrent.removeEventListener('canplay', handleCanPlay);
audioCurrent.removeEventListener('error', handleError);
reject(new Error('Audio failed to load'));
};
audioCurrent.addEventListener('canplay', handleCanPlay);
audioCurrent.addEventListener('error', handleError);
});
}
}
console.log('▶️ Attempting to play audio...');
await audioCurrent.play();
setIsPlaying(true);
setAudioInitialized(true);
onTrackPlay(currentTrack);
console.log('✅ Audio play successful');
} catch (error) {
console.error('❌ Failed to play audio:', error);
// Additional mobile-specific handling
if (isMobile) {
try {
console.log('🔄 Attempting mobile audio recovery...');
// Try creating and resuming audio context
const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (AudioContextClass) {
const audioContext = new AudioContextClass();
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
setAudioInitialized(true);
}
// Force load and retry
audioCurrent.load();
await new Promise(resolve => setTimeout(resolve, 200)); // Small delay for iOS
console.log('🔄 Retrying audio play...');
await audioCurrent.play();
setIsPlaying(true);
onTrackPlay(currentTrack);
}).catch((error) => {
console.error('Failed to play audio:', error);
console.log('✅ Audio play retry successful');
} catch (retryError) {
console.error('❌ Audio play retry failed:', retryError);
setIsPlaying(false);
// Show user-friendly error on mobile
toast({
variant: "destructive",
title: "Playback Error",
description: isPWA
? "Unable to play audio in PWA mode. Try refreshing the app or playing in Safari browser."
: "Unable to play audio. Please try again or check your connection.",
});
}
} else {
setIsPlaying(false);
}
}
}
}
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -354,9 +728,97 @@ export const AudioPlayer: React.FC = () => {
return null;
}
// Mini player (collapsed state)
// Mobile compact mini player :3
if (isMobile) {
return (
<>
<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">
<Progress
value={progress}
className="h-1 cursor-pointer progress-mobile"
onClick={handleProgressClick}
/>
</div>
<div className="flex items-center justify-between">
{/* Track info with swipe gestures */}
<div
className="flex items-center flex-1 min-w-0 cursor-pointer"
onClick={() => setIsFullScreen(true)}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<Image
src={currentTrack.coverArt || '/default-user.jpg'}
alt={currentTrack.name}
width={48}
height={48}
className="w-12 h-12 rounded-lg mr-3 shrink-0 shadow-sm"
/>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm truncate">{currentTrack.name}</p>
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
</div>
</div>
{/* Mobile controls - Only heart and play/pause */}
<div className="flex items-center space-x-2">
<button
className="p-3 hover:bg-muted/50 rounded-full transition-all duration-200 active:scale-95 touch-manipulation"
onClick={(e) => {
e.stopPropagation();
toggleCurrentTrackStar();
}}
type="button"
aria-label={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
className={`w-4 h-4 ${currentTrack.starred ? 'text-primary fill-primary' : ''}`}
/>
</button>
<button
className="p-4 hover:bg-muted/50 rounded-full transition-all duration-200 active:scale-95 bg-primary/10 touch-manipulation"
onClick={togglePlayPause}
style={{ touchAction: 'manipulation' }}
type="button"
data-testid="play-pause-button"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <FaPause className="w-5 h-5" /> : <FaPlay className="w-5 h-5" />}
</button>
</div>
</div>
</div>
</div>
{/* Full Screen Player for mobile - rendered outside mini player */}
<FullScreenPlayer
isOpen={isFullScreen}
onClose={() => setIsFullScreen(false)}
onOpenQueue={handleOpenQueue}
/>
{/* Single audio element - shared across all UI states */}
<audio
ref={audioRef}
playsInline
preload="metadata"
style={{ display: 'none' }}
/>
<audio ref={preloadAudioRef} hidden preload="metadata" />
</>
);
}
// Desktop mini player (collapsed state)
if (isMinimized) {
return (
<>
<div className="fixed bottom-4 left-4 z-50">
<div
className="bg-background/95 backdrop-blur-xs border rounded-lg shadow-lg cursor-pointer hover:scale-[1.02] transition-transform w-80"
@@ -404,14 +866,23 @@ export const AudioPlayer: React.FC = () => {
</div>
</div>
</div>
<audio ref={audioRef} hidden />
<audio ref={preloadAudioRef} hidden preload="metadata" />
</div>
{/* Single audio element - shared across all UI states */}
<audio
ref={audioRef}
playsInline
preload="metadata"
style={{ display: 'none' }}
/>
<audio ref={preloadAudioRef} hidden preload="metadata" />
</>
);
}
// Compact floating player (default state)
// Desktop compact floating player (default state)
return (
<>
<div className="fixed bottom-4 left-4 right-4 z-50">
<div className="bg-background/95 backdrop-blur-xs border rounded-lg shadow-lg p-3 cursor-pointer hover:scale-[1.01] transition-transform">
<div className="flex items-center">
@@ -500,8 +971,6 @@ export const AudioPlayer: React.FC = () => {
</div>
</div>
</div>
<audio ref={audioRef} hidden />
<audio ref={preloadAudioRef} hidden preload="metadata" />
{/* Full Screen Player */}
<FullScreenPlayer
@@ -510,5 +979,15 @@ export const AudioPlayer: React.FC = () => {
onOpenQueue={handleOpenQueue}
/>
</div>
{/* Single audio element - shared across all UI states with mobile support */}
<audio
ref={audioRef}
playsInline
preload="metadata"
style={{ display: 'none' }}
/>
<audio ref={preloadAudioRef} hidden preload="metadata" />
</>
);
};

View File

@@ -53,7 +53,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
const [isLoading, setIsLoading] = useState(false);
const [shuffle, setShuffle] = useState(false);
const { toast } = useToast();
const api = useMemo(() => getNavidromeAPI(), []);
const api = useMemo(() => {
const navidromeApi = getNavidromeAPI();
if (!navidromeApi) {
console.warn('⚠️ Navidrome API not configured');
} else {
console.log('✅ Navidrome API initialized');
}
return navidromeApi;
}, []);
useEffect(() => {
const savedQueue = localStorage.getItem('navidrome-audioQueue');
@@ -98,14 +106,18 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
if (!api) {
throw new Error('Navidrome API not configured');
}
const streamUrl = api.getStreamUrl(song.id);
console.log('🎵 Creating track with stream URL:', streamUrl);
return {
id: song.id,
name: song.title,
url: api.getStreamUrl(song.id),
url: streamUrl,
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 512) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred

View File

@@ -0,0 +1,67 @@
'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', label: 'Library', icon: Music },
{ 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 mb-2">
{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

@@ -143,7 +143,7 @@ export function CacheManagement() {
};
return (
<Card className="break-inside-avoid">
<Card className="break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />

View File

@@ -7,6 +7,7 @@ import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { Progress } from '@/components/ui/progress';
import { lrcLibClient } from '@/lib/lrclib';
import Link from 'next/link';
import { useIsMobile } from '@/hooks/use-mobile';
import {
FaPlay,
FaPause,
@@ -34,8 +35,20 @@ interface FullScreenPlayerProps {
onOpenQueue?: () => void;
}
type MobileTab = 'player' | 'lyrics' | 'queue';
export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onClose, onOpenQueue }) => {
const { currentTrack, playPreviousTrack, playNextTrack, shuffle, toggleShuffle, toggleCurrentTrackStar } = useAudioPlayer();
const {
currentTrack,
playPreviousTrack,
playNextTrack,
shuffle,
toggleShuffle,
toggleCurrentTrackStar,
queue
} = useAudioPlayer();
const isMobile = useIsMobile();
const router = useRouter();
const [progress, setProgress] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
@@ -47,8 +60,19 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
const [lyrics, setLyrics] = useState<LyricLine[]>([]);
const [currentLyricIndex, setCurrentLyricIndex] = useState(-1);
const [showLyrics, setShowLyrics] = useState(true);
const [activeTab, setActiveTab] = useState<MobileTab>('player');
const lyricsRef = useRef<HTMLDivElement>(null);
// Debug logging for component changes
useEffect(() => {
console.log('🔍 FullScreenPlayer state changed:', {
isOpen,
currentTrack,
currentTrackKeys: currentTrack ? Object.keys(currentTrack) : 'null',
queueLength: queue?.length || 0
});
}, [isOpen, currentTrack, queue?.length]);
// Load lyrics when track changes
useEffect(() => {
const loadLyrics = async () => {
@@ -72,7 +96,7 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
setLyrics([]);
}
} catch (error) {
console.error('Failed to load lyrics:', error);
console.log('Failed to load lyrics:', error);
setLyrics([]);
}
};
@@ -88,62 +112,106 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
}
}, [lyrics, currentTime, currentLyricIndex]);
// Auto-scroll lyrics using lyricsRef
// Auto-scroll lyrics using lyricsRef - Disabled on mobile to prevent iOS audio issues
useEffect(() => {
if (currentLyricIndex >= 0 && lyrics.length > 0 && showLyrics && lyricsRef.current) {
// Only auto-scroll on desktop to avoid iOS audio interference
const shouldScroll = !isMobile && showLyrics && lyrics.length > 0;
if (currentLyricIndex >= 0 && shouldScroll && lyricsRef.current) {
const scrollTimeout = setTimeout(() => {
// Find the ScrollArea viewport
const scrollViewport = lyricsRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
try {
const scrollContainer = lyricsRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
const currentLyricElement = lyricsRef.current?.querySelector(`[data-lyric-index="${currentLyricIndex}"]`) as HTMLElement;
if (scrollViewport && currentLyricElement) {
const containerHeight = scrollViewport.clientHeight;
if (scrollContainer && currentLyricElement) {
const containerHeight = scrollContainer.clientHeight;
const elementTop = currentLyricElement.offsetTop;
const elementHeight = currentLyricElement.offsetHeight;
// Calculate scroll position to center the current lyric
const targetScrollTop = elementTop - (containerHeight / 2) + (elementHeight / 2);
scrollViewport.scrollTo({
scrollContainer.scrollTo({
top: Math.max(0, targetScrollTop),
behavior: 'smooth'
});
}
}, 100);
} catch (error) {
console.warn('Lyrics scroll failed:', error);
}
}, 200);
return () => clearTimeout(scrollTimeout);
}
}, [currentLyricIndex, showLyrics, lyrics.length]);
}, [currentLyricIndex, showLyrics, lyrics.length, isMobile]);
// Reset lyrics to top when song changes
// Reset lyrics to top when song changes - Disabled on mobile to prevent iOS audio issues
useEffect(() => {
if (currentTrack && showLyrics && lyricsRef.current) {
// Reset scroll position using lyricsRef
const resetScroll = () => {
const scrollViewport = lyricsRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
// Only reset scroll on desktop to avoid iOS audio interference
const shouldReset = !isMobile && showLyrics && lyrics.length > 0;
if (scrollViewport) {
scrollViewport.scrollTo({
if (currentTrack?.id && shouldReset && lyricsRef.current) {
const resetTimeout = setTimeout(() => {
try {
const scrollContainer = lyricsRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
if (scrollContainer) {
scrollContainer.scrollTo({
top: 0,
behavior: 'instant' // Use instant for track changes
behavior: 'instant'
});
}
};
// Small delay to ensure DOM is ready
const resetTimeout = setTimeout(() => {
resetScroll();
} catch (error) {
console.warn('Lyrics reset scroll failed:', error);
}
setCurrentLyricIndex(-1);
}, 50);
return () => clearTimeout(resetTimeout);
}
}, [currentTrack?.id, showLyrics, currentTrack]); // Only reset when track ID changes
}, [currentTrack?.id, showLyrics, isMobile, lyrics.length]);
// Sync with main audio player (improved responsiveness)
useEffect(() => {
const syncWithMainPlayer = () => {
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
console.log('=== FULLSCREEN PLAYER AUDIO DEBUG ===');
console.log('currentTrack from context:', currentTrack);
console.log('currentTrack keys:', currentTrack ? Object.keys(currentTrack) : 'null');
if (currentTrack) {
console.log('currentTrack.url:', currentTrack.url);
console.log('currentTrack.id:', currentTrack.id);
console.log('currentTrack.name:', currentTrack.name);
console.log('currentTrack.artist:', currentTrack.artist);
}
console.log('Audio element found:', !!mainAudio);
if (mainAudio) {
console.log('Audio element src:', mainAudio.src);
console.log('Audio element currentSrc:', mainAudio.currentSrc);
console.log('Audio state:', {
currentTime: mainAudio.currentTime,
duration: mainAudio.duration,
paused: mainAudio.paused,
ended: mainAudio.ended,
readyState: mainAudio.readyState,
networkState: mainAudio.networkState,
error: mainAudio.error
});
// Check if audio source matches current track
if (currentTrack) {
const audioSourceMatches = mainAudio.src === currentTrack.url || mainAudio.currentSrc === currentTrack.url;
console.log('Audio source matches current track URL:', audioSourceMatches);
if (!audioSourceMatches) {
console.log('⚠️ Audio source mismatch!');
console.log('Expected:', currentTrack.url);
console.log('Audio src:', mainAudio.src);
console.log('Audio currentSrc:', mainAudio.currentSrc);
}
}
}
console.log('==========================================');
if (mainAudio && currentTrack) {
const newCurrentTime = mainAudio.currentTime;
const newDuration = mainAudio.duration || 0;
@@ -206,20 +274,96 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
setDominantColor(`rgb(${r}, ${g}, ${b})`);
}
} catch (error) {
console.error('Failed to extract color:', error);
console.log('Failed to extract color:', error);
}
};
img.src = currentTrack.coverArt;
}, [currentTrack]);
const togglePlayPause = () => {
console.log('🎵 FullScreenPlayer Toggle Play/Pause clicked');
// Find the main audio player's play/pause button and click it
// This ensures we use the same logic as the main player
const mainPlayButton = document.querySelector('[data-testid="play-pause-button"]') as HTMLButtonElement;
if (mainPlayButton) {
console.log('✅ Found main play button, triggering click');
mainPlayButton.click();
} else {
console.log('❌ Main play button not found, falling back to direct audio control');
// Fallback to direct audio control if button not found
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
if (!mainAudio) return;
if (!mainAudio) {
console.log('❌ No audio element found');
// Try to find ALL audio elements for debugging
const allAudio = document.querySelectorAll('audio');
console.log('🔍 Found audio elements:', allAudio.length);
allAudio.forEach((audio, index) => {
console.log(`Audio ${index}:`, {
src: audio.src,
currentSrc: audio.currentSrc,
paused: audio.paused,
hidden: audio.hidden,
style: audio.style.display
});
});
return;
}
console.log('🔍 Detailed audio element state:');
console.log('- Audio src:', mainAudio.src);
console.log('- Audio currentSrc:', mainAudio.currentSrc);
console.log('- Audio paused:', mainAudio.paused);
console.log('- Audio currentTime:', mainAudio.currentTime);
console.log('- Audio duration:', mainAudio.duration);
console.log('- Audio readyState:', mainAudio.readyState, '(0=HAVE_NOTHING, 1=HAVE_METADATA, 2=HAVE_CURRENT_DATA, 3=HAVE_FUTURE_DATA, 4=HAVE_ENOUGH_DATA)');
console.log('- Audio networkState:', mainAudio.networkState, '(0=EMPTY, 1=IDLE, 2=LOADING, 3=NO_SOURCE)');
console.log('- Audio error:', mainAudio.error);
console.log('- Audio ended:', mainAudio.ended);
console.log('- Audio seeking:', mainAudio.seeking);
console.log('- Audio volume:', mainAudio.volume);
console.log('- Audio muted:', mainAudio.muted);
console.log('- Audio autoplay:', mainAudio.autoplay);
console.log('- Audio loop:', mainAudio.loop);
console.log('- Audio preload:', mainAudio.preload);
console.log('- Audio crossOrigin:', mainAudio.crossOrigin);
if (isPlaying) {
console.log('⏸️ Attempting to pause audio');
try {
mainAudio.pause();
console.log('✅ Audio pause() succeeded');
} catch (error) {
console.log('❌ Audio pause() failed:', error);
}
} else {
mainAudio.play();
console.log('▶️ Attempting to play audio');
// Check if audio has a valid source
if (!mainAudio.src && !mainAudio.currentSrc) {
console.log('❌ Audio has no source set!');
console.log('currentTrack:', currentTrack);
if (currentTrack) {
console.log('Setting audio source to:', currentTrack.url);
mainAudio.src = currentTrack.url;
mainAudio.load();
}
}
mainAudio.play().then(() => {
console.log('✅ Audio play() succeeded');
}).catch((error) => {
console.log('❌ Audio play() failed:', error);
console.log('Error details:', {
name: error.name,
message: error.message,
code: error.code
});
});
}
}
};
@@ -269,27 +413,68 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
if (!isOpen || !currentTrack) return null;
return (
<div className="fixed inset-0 z-50 bg-black overflow-hidden">
{/* Blurred background image */}
<div className="fixed inset-0 z-[70] bg-black overflow-hidden">
{/* Enhanced Blurred background image */}
{currentTrack.coverArt && (
<div className="absolute inset-0 w-full h-full">
{/* Main background */}
<div
className="absolute inset-0 w-full h-full"
style={{
backgroundImage: `url(${currentTrack.coverArt})`,
backgroundSize: '120%',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
filter: 'blur(20px) brightness(0.3)',
transform: 'scale(1.1)',
}}
/>
{/* Top gradient blur for mobile */}
<div
className="absolute top-0 left-0 right-0 h-32"
style={{
background: `linear-gradient(to bottom,
rgba(0,0,0,0.8) 0%,
rgba(0,0,0,0.4) 50%,
transparent 100%)`,
backdropFilter: 'blur(10px)',
}}
/>
{/* Bottom gradient blur for mobile */}
<div
className="absolute bottom-0 left-0 right-0 h-32"
style={{
background: `linear-gradient(to top,
rgba(0,0,0,0.8) 0%,
rgba(0,0,0,0.4) 50%,
transparent 100%)`,
backdropFilter: 'blur(10px)',
}}
/>
</div>
)}
{/* Overlay for better contrast */}
<div className="absolute inset-0 bg-black/50" />
<div className="relative h-full w-full">
{/* Floating Header */}
<div className="absolute top-0 right-0 z-50 p-4 lg:p-6">
<div className="absolute inset-0 bg-black/30" />
<div className="relative h-full w-full flex flex-col">
{/* Mobile Close Handle */}
{isMobile && (
<div className="flex justify-center py-4 px-4">
<div
onClick={onClose}
className="cursor-pointer px-8 py-3 -mx-8 -my-3"
style={{ touchAction: 'manipulation' }}
>
<div className="w-8 h-1 bg-gray-300 rounded-full opacity-60" />
</div>
</div>
)}
{/* Desktop Header */}
{!isMobile && (
<div className="absolute top-0 right-0 z-10 p-4 lg:p-6">
<div className="flex items-center gap-2">
{onOpenQueue && (
<button
@@ -309,38 +494,272 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
</button>
</div>
</div>
)}
{/* Main Content */}
<div className="h-full flex flex-col lg:flex-row gap-4 lg:gap-8 p-4 lg:p-6 overflow-hidden">
{/* Left Side - Album Art and Controls */}
<div className="flex flex-col items-center justify-center min-h-0 flex-1 min-w-0">
{/* Album Art */}
<div className="relative mb-4 lg:mb-6 shrink-0">
<div className="flex-1 overflow-hidden">
{isMobile ? (
/* Mobile Tab Content */
<div className="h-full flex flex-col">
<div className="flex-1 overflow-hidden">
{activeTab === 'player' && (
<div className="h-full flex flex-col justify-center items-center px-8 py-4">
{/* Mobile Album Art */}
<div className="relative mb-6 shrink-0">
<Image
src={currentTrack.coverArt || '/default-album.png'}
alt={currentTrack.album}
width={320}
height={320}
className="w-56 h-56 sm:w-64 sm:h-64 lg:w-80 lg:h-80 rounded-lg shadow-2xl object-cover"
width={260}
height={260}
className={`rounded-lg shadow-2xl object-cover transition-all duration-300 ${
!isPlaying ? 'w-52 h-52 opacity-70 scale-95' : 'w-64 h-64'
}`}
priority
/>
</div>
{/* Track Info */}
<div className="text-center mb-4 lg:mb-6 px-4 shrink-0 max-w-full">
<h1 className="text-lg sm:text-xl lg:text-3xl font-bold text-foreground mb-2 line-clamp-2 leading-tight">
{/* Track Info - Left Aligned and Heart on Same Line */}
<div className="w-full mb-6 shrink-0">
<div className="flex items-center justify-between mb-0">
<h1 className="text-2xl font-bold text-foreground line-clamp-1 flex-1 text-left">
{currentTrack.name}
</h1>
<Link href={`/artist/${currentTrack.artistId}`} className="text-base sm:text-lg lg:text-xl text-foreground/80 mb-1 line-clamp-1">
<button
onClick={toggleCurrentTrackStar}
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors ml-3 pb-0"
title={currentTrack?.starred ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
className={`w-6 h-6 ${currentTrack?.starred ? 'text-primary fill-primary' : 'text-gray-400'}`}
/>
</button>
</div>
<Link
href={`/artist/${currentTrack.artistId}`}
className="text-lg text-foreground/80 line-clamp-1 block text-left mb-1"
>
{currentTrack.artist}
</Link>
<Link href={`/album/${currentTrack.albumId}`} className="text-sm sm:text-base lg:text-lg text-foreground/60 line-clamp-1 cursor-pointer hover:underline">
<Link
href={`/album/${currentTrack.albumId}`}
className="text-base text-foreground/60 line-clamp-1 cursor-pointer hover:underline block text-left"
>
{currentTrack.album}
</Link>
</div>
{/* Progress */}
<div className="w-full max-w-sm lg:max-w-md mb-4 lg:mb-6 px-4 shrink-0">
<div className="w-full mb-4 shrink-0">
<div className="w-full" onClick={handleSeek}>
<Progress value={progress} className="h-2 cursor-pointer" />
</div>
{/* Time below progress on mobile */}
<div className="flex justify-between text-sm text-foreground/60 mt-2">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-6 mb-4 shrink-0">
<button
onClick={toggleShuffle}
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${
shuffle ? 'text-primary bg-primary/20' : 'text-gray-400'
}`}
title={shuffle ? 'Shuffle On - Queue is shuffled' : 'Shuffle Off - Click to shuffle queue'}
>
<FaShuffle className="w-5 h-5" />
</button>
<button
onClick={playPreviousTrack}
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
<FaBackward className="w-6 h-6" />
</button>
<button
onClick={togglePlayPause}
className="p-4 hover:bg-gray-700/50 rounded-full transition-colors">
{isPlaying ? (
<FaPause className="w-10 h-10" />
) : (
<FaPlay className="w-10 h-10" />
)}
</button>
<button
onClick={playNextTrack}
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
<FaForward className="w-6 h-6" />
</button>
<button
onMouseEnter={() => setShowVolumeSlider(true)}
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
{volume === 0 ? (
<FaVolumeXmark className="w-5 h-5" />
) : (
<FaVolumeHigh className="w-5 h-5" />
)}
</button>
</div>
{/* Volume Slider */}
{showVolumeSlider && (
<div
className="w-32 mb-4"
onMouseLeave={() => setShowVolumeSlider(false)}
>
<input
type="range"
min="0"
max="100"
value={volume * 100}
onChange={handleVolumeChange}
className="w-full accent-foreground"
/>
</div>
)}
</div>
)}
{activeTab === 'lyrics' && lyrics.length > 0 && (
<div className="h-full flex flex-col px-4">
<div
className="flex-1 overflow-y-auto"
ref={lyricsRef}
>
<div className="space-y-3 py-4">
{lyrics.map((line, index) => (
<div
key={index}
data-lyric-index={index}
onClick={() => handleLyricClick(line.time)}
className={`text-base leading-relaxed transition-all duration-300 break-words cursor-pointer hover:text-foreground px-2 ${
index === currentLyricIndex
? 'text-foreground font-bold text-xl'
: index < currentLyricIndex
? 'text-foreground/60'
: 'text-foreground/40'
}`}
style={{
wordWrap: 'break-word',
overflowWrap: 'break-word',
hyphens: 'auto',
paddingBottom: '4px'
}}
title={`Click to jump to ${formatTime(line.time)}`}
>
{line.text || '♪'}
</div>
))}
<div style={{ height: '200px' }} />
</div>
</div>
</div>
)}
{activeTab === 'queue' && (
<div className="h-full flex flex-col px-4">
<ScrollArea className="flex-1">
<div className="space-y-2 py-4">
{queue.map((track, index) => (
<div
key={`${track.id}-${index}`}
className={`flex items-center p-3 rounded-lg ${
track.id === currentTrack?.id ? 'bg-primary/20' : 'bg-gray-800/30'
}`}
>
<Image
src={track.coverArt || '/default-album.png'}
alt={track.album}
width={40}
height={40}
className="rounded mr-3"
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
{track.name}
</p>
<p className="text-xs text-gray-400 truncate">
{track.artist}
</p>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
</div>
{/* Mobile Tab Bar */}
<div className="flex-shrink-0 pb-safe">
<div className="flex justify-around py-4 mb-2">
<button
onClick={() => setActiveTab('player')}
className={`flex items-center justify-center p-4 rounded-full transition-colors ${
activeTab === 'player' ? 'text-primary bg-primary/20' : 'text-gray-400'
}`}
>
<FaPlay className="w-6 h-6" />
</button>
{lyrics.length > 0 && (
<button
onClick={() => setActiveTab('lyrics')}
className={`flex items-center justify-center p-4 rounded-full transition-colors ${
activeTab === 'lyrics' ? 'text-primary bg-primary/20' : 'text-gray-400'
}`}
>
<FaQuoteLeft className="w-6 h-6" />
</button>
)}
<button
onClick={() => setActiveTab('queue')}
className={`flex items-center justify-center p-4 rounded-full transition-colors ${
activeTab === 'queue' ? 'text-primary bg-primary/20' : 'text-gray-400'
}`}
>
<FaListUl className="w-6 h-6" />
</button>
</div>
</div>
</div>
) : (
/* Desktop Layout */
<div className="h-full flex flex-row gap-8 p-6 overflow-hidden">
{/* Left Side - Album Art and Controls */}
<div className="flex flex-col items-center justify-center min-h-0 flex-1 min-w-0">
{/* Album Art */}
<div className="relative mb-6 shrink-0">
<Image
src={currentTrack.coverArt || '/default-album.png'}
alt={currentTrack.album}
width={320}
height={320}
className="w-80 h-80 rounded-lg shadow-2xl object-cover"
priority
/>
</div>
{/* Track Info */}
<div className="text-center mb-6 px-4 shrink-0 max-w-full">
<h1 className="text-3xl font-bold text-foreground line-clamp-2 leading-tight mb-2">
{currentTrack.name}
</h1>
<Link href={`/artist/${currentTrack.artistId}`} className="text-xl text-foreground/80 mb-1 line-clamp-1">
{currentTrack.artist}
</Link>
<Link href={`/album/${currentTrack.albumId}`} className="text-lg text-foreground/60 line-clamp-1 cursor-pointer hover:underline">
{currentTrack.album}
</Link>
</div>
{/* Progress */}
<div className="w-full max-w-md mb-6 px-4 shrink-0">
<div className="w-full" onClick={handleSeek}>
<Progress value={progress} className="h-2 cursor-pointer" />
</div>
@@ -351,7 +770,7 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
</div>
{/* Controls */}
<div className="flex items-center gap-3 sm:gap-4 lg:gap-6 mb-4 lg:mb-6 shrink-0">
<div className="flex items-center gap-6 mb-6 shrink-0">
<button
onClick={toggleShuffle}
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${
@@ -359,29 +778,29 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
}`}
title={shuffle ? 'Shuffle On - Queue is shuffled' : 'Shuffle Off - Click to shuffle queue'}
>
<FaShuffle className="w-4 h-4 sm:w-5 sm:h-5" />
<FaShuffle className="w-5 h-5" />
</button>
<button
onClick={playPreviousTrack}
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
<FaBackward className="w-4 h-4 sm:w-5 sm:h-5" />
<FaBackward className="w-5 h-5" />
</button>
<button
onClick={togglePlayPause}
className="p-3 hover:bg-gray-700/50 rounded-full transition-colors">
{isPlaying ? (
<FaPause className="w-8 h-8 sm:w-10 sm:h-10" />
<FaPause className="w-10 h-10" />
) : (
<FaPlay className="w-8 h-8 sm:w-10 sm:h-10" />
<FaPlay className="w-10 h-10" />
)}
</button>
<button
onClick={playNextTrack}
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
<FaForward className="w-4 h-4 sm:w-5 sm:h-5" />
<FaForward className="w-5 h-5" />
</button>
<button
@@ -390,23 +809,20 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
title={currentTrack?.starred ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
className={`w-4 h-4 sm:w-5 sm:h-5 ${currentTrack?.starred ? 'text-primary fill-primary' : 'text-gray-400'}`}
className={`w-5 h-5 ${currentTrack?.starred ? 'text-primary fill-primary' : 'text-gray-400'}`}
/>
</button>
</div>
{/* Volume and Lyrics Toggle */}
{/* Volume and Lyrics Toggle - Desktop Only */}
<div className="flex items-center gap-3 shrink-0 justify-center">
<button
onMouseEnter={() => setShowVolumeSlider(true)}
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
{volume === 0 ? (
<FaVolumeXmark className="w-4 h-4 sm:w-5 sm:h-5" />
<FaVolumeXmark className="w-5 h-5" />
) : (
<FaVolumeHigh className="w-4 h-4 sm:w-5 sm:h-5" />
<FaVolumeHigh className="w-5 h-5" />
)}
</button>
@@ -418,13 +834,13 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
}`}
title={showLyrics ? 'Hide Lyrics' : 'Show Lyrics'}
>
<FaQuoteLeft className="w-4 h-4 sm:w-5 sm:h-5" />
<FaQuoteLeft className="w-5 h-5" />
</button>
)}
{showVolumeSlider && (
<div
className="w-16 sm:w-20 lg:w-24"
className="w-24"
onMouseLeave={() => setShowVolumeSlider(false)}
>
<input
@@ -440,18 +856,18 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
</div>
</div>
{/* Right Side - Lyrics */}
{/* Right Side - Lyrics (Desktop Only) */}
{showLyrics && lyrics.length > 0 && (
<div className="flex-1 min-w-0 min-h-0 flex flex-col" ref={lyricsRef}>
<div className="h-full flex flex-col">
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-2 sm:space-y-3 pl-4 pr-4 py-4">
<div className="space-y-3 pl-4 pr-4 py-4">
{lyrics.map((line, index) => (
<div
key={index}
data-lyric-index={index}
onClick={() => handleLyricClick(line.time)}
className={`text-sm sm:text-base lg:text-base leading-relaxed transition-all duration-300 break-words cursor-pointer hover:text-foreground ${
className={`text-base leading-relaxed transition-all duration-300 break-words cursor-pointer hover:text-foreground ${
index === currentLyricIndex
? 'text-foreground font-bold text-2xl'
: index < currentLyricIndex
@@ -470,7 +886,6 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
{line.text || '♪'}
</div>
))}
{/* Add extra padding at the bottom to allow last lyric to center */}
<div style={{ height: '200px' }} />
</div>
</ScrollArea>
@@ -478,6 +893,8 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
</div>
)}
</div>
)}
</div>
</div>
</div>
);

View File

@@ -36,7 +36,7 @@ export function PopularSongs({ songs, artistName }: PopularSongsProps) {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
@@ -95,7 +95,7 @@ export function PopularSongs({ songs, artistName }: PopularSongsProps) {
<div className="relative w-12 h-12 bg-muted rounded-md overflow-hidden shrink-0">
{song.coverArt && api && (
<Image
src={api.getCoverArtUrl(song.coverArt, 96)}
src={api.getCoverArtUrl(song.coverArt, 300)}
alt={song.album}
width={48}
height={48}

View File

@@ -9,6 +9,8 @@ import { PostHogProvider } from "../components/PostHogProvider";
import { WhatsNewPopup } from "../components/WhatsNewPopup";
import Ihateserverside from "./ihateserverside";
import DynamicViewportTheme from "./DynamicViewportTheme";
import ThemeColorHandler from "./ThemeColorHandler";
import { useViewportThemeColor } from "@/hooks/use-viewport-theme-color";
import { LoginForm } from "./start-screen";
import Image from "next/image";
@@ -83,6 +85,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
<PostHogProvider>
<ThemeProvider>
<DynamicViewportTheme />
<ThemeColorHandler />
<NavidromeConfigProvider>
<NavidromeProvider>
<NavidromeErrorBoundary>

View File

@@ -38,7 +38,7 @@ export function SettingsManagement() {
};
return (
<Card>
<Card className="py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" />

View File

@@ -153,7 +153,7 @@ export function SidebarCustomization() {
return (
<Card>
<Card className="py-5">
<CardHeader>
<CardTitle>Sidebar Customization</CardTitle>
<CardDescription>

View File

@@ -1,14 +1,16 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Song } from '@/lib/navidrome';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Song, Album } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { useIsMobile } from '@/hooks/use-mobile';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Play, Heart, Music, Shuffle } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { UserProfile } from './UserProfile';
interface SongRecommendationsProps {
userName?: string;
@@ -17,14 +19,26 @@ interface SongRecommendationsProps {
export function SongRecommendations({ userName }: SongRecommendationsProps) {
const { api, isConnected } = useNavidrome();
const { playTrack, shuffle, toggleShuffle } = useAudioPlayer();
const isMobile = useIsMobile();
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
const [recommendedAlbums, setRecommendedAlbums] = useState<Album[]>([]);
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
// Memoize the greeting to prevent recalculation
const greeting = useMemo(() => {
const hour = new Date().getHours();
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
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 () => {
@@ -32,8 +46,14 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
setLoading(true);
try {
// Get random albums and extract songs from them
const randomAlbums = await api.getAlbums('random', 10); // Get 10 random albums
// Get random albums for both mobile album view and desktop song extraction
const randomAlbums = await api.getAlbums('random', 10);
if (isMobile) {
// For mobile: show 6 random albums
setRecommendedAlbums(randomAlbums.slice(0, 6));
} else {
// For desktop: extract songs from albums (original behavior)
const allSongs: Song[] = [];
// Get songs from first few albums
@@ -51,24 +71,22 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
const recommendations = shuffled.slice(0, 6);
setRecommendedSongs(recommendations);
// Initialize starred states and image loading states
// Initialize starred states for songs
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);
console.error('Failed to load recommendations:', error);
} finally {
setLoading(false);
}
};
loadRecommendations();
}, [api, isConnected]);
}, [api, isConnected, isMobile]);
const handlePlaySong = async (song: Song) => {
if (!api) return;
@@ -83,7 +101,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
album: song.album || 'Unknown Album',
albumId: song.albumId || '',
duration: song.duration || 0,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
starred: !!song.starred
};
await playTrack(track, true);
@@ -92,17 +110,50 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
}
};
const handlePlayAlbum = async (album: Album) => {
if (!api) return;
try {
// Get album songs and play the first one
const albumSongs = await api.getAlbumSongs(album.id);
if (albumSongs.length > 0) {
const track = {
id: albumSongs[0].id,
name: albumSongs[0].title,
url: api.getStreamUrl(albumSongs[0].id),
artist: albumSongs[0].artist || 'Unknown Artist',
artistId: albumSongs[0].artistId || '',
album: albumSongs[0].album || 'Unknown Album',
albumId: albumSongs[0].albumId || '',
duration: albumSongs[0].duration || 0,
coverArt: albumSongs[0].coverArt ? api.getCoverArtUrl(albumSongs[0].coverArt, 64) : undefined,
starred: !!albumSongs[0].starred
};
await playTrack(track, true);
}
} catch (error) {
console.error('Failed to play album:', error);
}
};
const handleShuffleAll = async () => {
if (recommendedSongs.length === 0) return;
if (isMobile && recommendedAlbums.length === 0) return;
if (!isMobile && recommendedSongs.length === 0) return;
// Enable shuffle if not already on
if (!shuffle) {
toggleShuffle();
}
if (isMobile) {
// Play a random album
const randomAlbum = recommendedAlbums[Math.floor(Math.random() * recommendedAlbums.length)];
await handlePlayAlbum(randomAlbum);
} else {
// Play a random song from recommendations
const randomSong = recommendedSongs[Math.floor(Math.random() * recommendedSongs.length)];
await handlePlaySong(randomSong);
}
};
const formatDuration = (duration: number): string => {
@@ -118,11 +169,19 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-64 bg-muted animate-pulse rounded" />
</div>
{isMobile ? (
<div className="grid grid-cols-3 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="aspect-square bg-muted animate-pulse rounded" />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
))}
</div>
)}
</div>
);
}
@@ -135,18 +194,83 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
{greeting}{userName ? `, ${userName}` : ''}!
</h2>
<p className="text-muted-foreground">
Here are some songs you might enjoy
{isMobile ? 'Here are some albums you might enjoy' : 'Here are some songs you might enjoy'}
</p>
</div>
{recommendedSongs.length > 0 && (
<div className="flex items-center gap-3">
{/* Mobile User Profile */}
{isMobile && <UserProfile variant="mobile" />}
{/* Shuffle All Button (Desktop only) */}
{(isMobile ? recommendedAlbums.length > 0 : recommendedSongs.length > 0) && !isMobile && (
<Button onClick={handleShuffleAll} variant="outline" size="sm">
<Shuffle className="w-4 h-4 mr-2" />
Shuffle All
</Button>
)}
</div>
</div>
{recommendedSongs.length > 0 ? (
{isMobile ? (
/* Mobile: Show albums in 3x2 grid */
recommendedAlbums.length > 0 ? (
<div className="grid grid-cols-3 gap-3">
{recommendedAlbums.map((album) => (
<div key={album.id} className="space-y-2">
<Link
href={`/album/${album.id}`}
className="group cursor-pointer block"
>
<div className="relative aspect-square rounded-lg overflow-hidden bg-muted">
{album.coverArt && api ? (
<Image
src={api.getCoverArtUrl(album.coverArt, 300)}
alt={album.name}
width={600}
height={600}
className="object-cover"
sizes="(max-width: 768px) 33vw, 200px"
onLoad={handleImageLoad}
onError={handleImageError}
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Music className="w-8 h-8 text-muted-foreground" />
</div>
)}
</div>
</Link>
<div className="space-y-1">
<Link
href={`/album/${album.id}`}
className="font-medium text-sm truncate hover:underline block"
>
{album.name}
</Link>
<Link
href={`/artist/${album.artistId || album.artist}`}
className="text-xs text-muted-foreground truncate hover:underline block"
>
{album.artist}
</Link>
</div>
</div>
))}
</div>
) : (
<Card>
<CardContent className="p-6 text-center">
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
No albums available for recommendations
</p>
</CardContent>
</Card>
)
) : (
/* Desktop: Show songs in original format */
recommendedSongs.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{recommendedSongs.map((song) => (
<Card
@@ -159,33 +283,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)}
src={api.getCoverArtUrl(song.coverArt, 48)}
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">
@@ -224,6 +340,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
</p>
</CardContent>
</Card>
)
)}
</div>
);

View File

@@ -0,0 +1,8 @@
'use client';
import { useViewportThemeColor } from '@/hooks/use-viewport-theme-color';
export default function ThemeColorHandler() {
useViewportThemeColor();
return null;
}

View File

@@ -0,0 +1,209 @@
'use client';
import React, { useState, useEffect } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { User, ChevronDown, Settings, LogOut } from 'lucide-react';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { getGravatarUrl } from '@/lib/gravatar';
import { User as NavidromeUser } from '@/lib/navidrome';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
interface UserProfileProps {
variant?: 'desktop' | 'mobile';
}
export function UserProfile({ variant = 'desktop' }: UserProfileProps) {
const { api, isConnected } = useNavidrome();
const [userInfo, setUserInfo] = useState<NavidromeUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchUserInfo = async () => {
if (!api || !isConnected) {
setLoading(false);
return;
}
try {
const user = await api.getUserInfo();
setUserInfo(user);
} catch (error) {
console.error('Failed to fetch user info:', error);
} finally {
setLoading(false);
}
};
fetchUserInfo();
}, [api, isConnected]);
const handleLogout = () => {
// Clear Navidrome config and reload
localStorage.removeItem('navidrome-config');
window.location.reload();
};
if (!userInfo) {
if (variant === 'desktop') {
return (
<Link href="/settings">
<Button variant="ghost" size="sm" className="gap-2">
<User className="w-4 h-4" />
</Button>
</Link>
);
} else {
return (
<Link href="/settings">
<Button variant="ghost" size="sm" className="gap-2">
<User className="w-4 h-4" />
</Button>
</Link>
);
}
}
const gravatarUrl = userInfo.email
? getGravatarUrl(userInfo.email, variant === 'desktop' ? 32 : 48, 'identicon')
: null;
if (variant === 'desktop') {
// Desktop: Only show profile icon
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-1 h-auto p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={16}
height={16}
className="rounded-full"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
) : (
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-primary" />
</div>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-2 p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={16}
height={16}
className="rounded-full"
/>
) : (
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-primary" />
</div>
)}
<div>
<p className="text-sm font-medium">{userInfo.username}</p>
{userInfo.email && (
<p className="text-xs text-muted-foreground">{userInfo.email}</p>
)}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings" className="flex items-center gap-2">
<Settings className="w-4 h-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
className="flex items-center gap-2 text-red-600 focus:text-red-600"
>
<LogOut className="w-4 h-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
} else {
// Mobile: Show only icon with dropdown
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-1 h-auto p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={32}
height={32}
className="rounded-full"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
) : (
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-primary" />
</div>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-2 p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={32}
height={32}
className="rounded-full"
/>
) : (
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-primary" />
</div>
)}
<div>
<p className="text-sm font-medium">{userInfo.username}</p>
{userInfo.email && (
<p className="text-xs text-muted-foreground">{userInfo.email}</p>
)}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings" className="flex items-center gap-2">
<Settings className="w-4 h-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
className="flex items-center gap-2 text-red-600 focus:text-red-600"
>
<LogOut className="w-4 h-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
}

View File

@@ -1,16 +1,33 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
// Current app version from package.json
const APP_VERSION = '2025.07.10';
const APP_VERSION = '2025.07.31';
// Changelog data - add new versions at the top
const CHANGELOG = [
{
version: '2025.07.31',
title: 'July End of Month Update',
changes: [
'Native support for moblie devices (using pwa)',
],
fixes: [
'Fixed issue with mobile navigation bar not displaying correctly',
'Improved performance on mobile devices',
'Resolved layout issues on smaller screens',
'Fixed audio player controls not responding on mobile',
'Improved touch interactions for better usability',
'Fixed issue with album artwork not loading on mobile',
'Resolved bug with search functionality on mobile devices',
'Improved caching for faster load times on mobile',
],
breaking: [
]
},
{
version: '2025.07.10',
title: 'July Major Update',
@@ -189,22 +206,37 @@ export function WhatsNewPopup() {
);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50"
onClick={handleClose}
/>
{/* Dialog content */}
<div className="relative bg-background rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex flex-row items-center justify-between space-y-0 p-6 pb-4 shrink-0">
<div>
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
<h2 className="text-2xl font-bold flex items-center gap-2">
What&apos;s New in Mice
<Badge variant="outline">
{tab === 'latest' ? currentVersionChangelog.version : archiveChangelog?.version}
</Badge>
</DialogTitle>
</h2>
</div>
<button
onClick={handleClose}
className="text-muted-foreground hover:text-foreground transition-colors"
>
</button>
</div>
</DialogHeader>
{/* Tabs */}
<>
<div className="flex gap-2 mb-4">
<div className="flex gap-2 px-6 pt-4 shrink-0">
<Button
variant={tab === 'latest' ? 'default' : 'outline'}
size="sm"
@@ -222,7 +254,7 @@ export function WhatsNewPopup() {
</Button>
{tab === 'archive' && archiveChangelogs.length > 0 && (
<select
className="ml-2 border rounded px-2 py-1 text-sm"
className="ml-2 border rounded px-2 py-1 text-sm bg-background"
value={selectedArchive}
onChange={e => setSelectedArchive(e.target.value)}
>
@@ -234,20 +266,26 @@ export function WhatsNewPopup() {
</select>
)}
</div>
<ScrollArea className="max-h-[60vh] pr-4">
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
<div className="space-y-6">
{tab === 'latest'
? renderChangelog(currentVersionChangelog)
: archiveChangelog && renderChangelog(archiveChangelog)}
</ScrollArea>
</div>
</div>
<div className="flex justify-center pt-4">
{/* Footer button */}
<div className="flex justify-center p-6 pt-4 shrink-0">
<Button onClick={handleClose}>
Got it!
</Button>
</div>
</div>
</div>
)}
</>
</DialogContent>
</Dialog>
);
}

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,24 @@ 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 with dynamic sizing
const coverArtUrl = useMemo(() => {
if (!api || !album.coverArt) return '/default-user.jpg';
// Use width prop or default size for optimization
const imageSize = width || height || 300;
return api.getCoverArtUrl(album.coverArt, imageSize);
}, [api, album.coverArt, width, height]);
// 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}`);
@@ -80,7 +96,7 @@ export function AlbumArtwork({
artistId: song.artistId,
url: api.getStreamUrl(song.id),
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
starred: !!song.starred
}));
@@ -105,10 +121,6 @@ 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}>
@@ -117,54 +129,32 @@ export function AlbumArtwork({
<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)}
src={coverArtUrl}
alt={album.name}
fill
className={`w-full h-full object-cover transition-opacity duration-300 ${
imageLoading ? 'opacity-0' : 'opacity-100'
}`}
className="w-full h-full object-cover transition-all"
sizes="(max-width: 768px) 100vw, 300px"
onLoad={() => setImageLoading(false)}
onError={() => {
setImageLoading(false);
setImageError(true);
}}
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>
)}
{!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 onClick={handleClick} className="overflow-hidden rounded-md">

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";
@@ -96,7 +97,33 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
</div>
);
}
return (
<>
{/* Mobile Layout */}
<div className="flex md:hidden flex-col h-screen w-screen overflow-hidden">
{/* Top Menu */}
{/* <div className="shrink-0 bg-background border-b w-full">
<Menu
toggleSidebar={toggleSidebarVisibility}
isSidebarVisible={isSidebarVisible}
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
isStatusBarVisible={isStatusBarVisible}
/>
</div> */}
{/* 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>
{/* Bottom Navigation for Mobile */}
<BottomNavigation />
<Toaster />
</div>
{/* Desktop Layout */}
<div className="hidden md:flex md:flex-col md:h-screen md:w-screen md:overflow-hidden">
{/* Top Menu */}
<div
@@ -132,12 +159,12 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
</div>
</div>
{/* Floating Audio Player */}
{isStatusBarVisible && (
<AudioPlayer />
)}
<Toaster />
</div>
{/* Single Shared Audio Player - shows on all layouts */}
<AudioPlayer />
</>
);
};

View File

@@ -1,7 +1,8 @@
import { useCallback } from "react";
import { useRouter } from 'next/navigation';
import Image from "next/image";
import { Github, Mail } from "lucide-react"
import { Github, Mail, Menu as MenuIcon, X } from "lucide-react"
import { UserProfile } from "@/app/components/UserProfile";
import {
Menubar,
MenubarCheckboxItem,
@@ -28,9 +29,35 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
} from "@/components/ui/dialog"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useIsMobile } from "@/hooks/use-mobile"
import Link from "next/link"
import {
Search,
Home,
List,
Radio,
Users,
Disc,
Music,
Heart,
Grid3X3,
Clock,
Settings,
Circle
} from "lucide-react";
interface MenuProps {
toggleSidebar: () => void;
@@ -43,9 +70,27 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
const [isFullScreen, setIsFullScreen] = useState(false)
const router = useRouter();
const [open, setOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { isConnected } = useNavidrome();
const [isClient, setIsClient] = useState(false);
const [navidromeUrl, setNavidromeUrl] = useState<string | null>(null);
const isMobile = useIsMobile();
// Navigation items for mobile menu
const navigationItems = [
{ 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: '/library/songs', label: 'Songs', icon: Circle },
{ href: '/library/playlists', label: 'Playlists', icon: Music },
{ href: '/favorites', label: 'Favorites', icon: Heart },
{ href: '/queue', label: 'Queue', icon: List },
{ href: '/radio', label: 'Radio', icon: Radio },
{ href: '/browse', label: 'Browse', icon: Grid3X3 },
{ href: '/history', label: 'History', icon: Clock },
{ href: '/settings', label: 'Settings', icon: Settings },
];
// For this demo, we'll show connection status instead of user auth
const connectionStatus = isConnected ? "Connected to Navidrome" : "Not connected";
@@ -112,6 +157,13 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
return (
<>
<div className="flex items-center justify-between w-full">
{/* Mobile Top Bar - Simplified since navigation is now at bottom */}
{isMobile ? (
// hey bear!
// nothing
null
) : (
/* Desktop Navigation */
<Menubar
className="rounded-none border-b border-none px-2 lg:px-2 flex-1 min-w-0"
style={{
@@ -279,6 +331,14 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
</MenubarMenu>
</div>
</Menubar>
)}
{/* User Profile - Desktop only */}
{!isMobile && (
<div className="ml-auto">
<UserProfile variant="desktop" />
</div>
)}
</div>

View File

@@ -109,7 +109,7 @@ export function Sidebar({ className, playlists, visible = true, favoriteAlbums =
>
{album.coverArt && api ? (
<Image
src={api.getCoverArtUrl(album.coverArt, 32)}
src={api.getCoverArtUrl(album.coverArt, 150)}
alt={album.name}
width={16}
height={16}
@@ -165,7 +165,7 @@ export function Sidebar({ className, playlists, visible = true, favoriteAlbums =
>
{album.coverArt && api ? (
<Image
src={api.getCoverArtUrl(album.coverArt, 32)}
src={api.getCoverArtUrl(album.coverArt, 150)}
alt={album.name}
width={16}
height={16}

View File

@@ -17,7 +17,7 @@ import { Badge } from '@/components/ui/badge';
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
import { useTheme } from '@/app/components/ThemeProvider';
import { useToast } from '@/hooks/use-toast';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaPalette, FaLastfm, FaBars } from 'react-icons/fa';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaPalette, FaLastfm } from 'react-icons/fa';
export function LoginForm({
className,
@@ -45,20 +45,7 @@ export function LoginForm({
return true;
});
// New settings
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('sidebar-collapsed') === 'true';
}
return false;
});
const [standaloneLastfmEnabled, setStandaloneLastfmEnabled] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('standalone-lastfm-enabled') === 'true';
}
return false;
});
// New settings - removed sidebar and standalone lastfm options
// Check if Navidrome is configured via environment variables
const hasEnvConfig = React.useMemo(() => {
@@ -187,8 +174,6 @@ export function LoginForm({
const handleFinishSetup = () => {
// Save all settings
localStorage.setItem('lastfm-scrobbling-enabled', scrobblingEnabled.toString());
localStorage.setItem('sidebar-collapsed', sidebarCollapsed.toString());
localStorage.setItem('standalone-lastfm-enabled', standaloneLastfmEnabled.toString());
// Mark onboarding as complete
localStorage.setItem('onboarding-completed', '1.1.0');
@@ -252,7 +237,7 @@ export function LoginForm({
if (step === 'settings') {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<Card className='py-5'>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaPalette className="w-5 h-5" />
@@ -286,29 +271,6 @@ export function LoginForm({
</Select>
</div>
{/* Sidebar Settings */}
<div className="grid gap-3">
<Label className="flex items-center gap-2">
<FaBars className="w-4 h-4" />
Sidebar Layout
</Label>
<Select
value={sidebarCollapsed ? "collapsed" : "expanded"}
onValueChange={(value) => setSidebarCollapsed(value === "collapsed")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="expanded">Expanded (with labels)</SelectItem>
<SelectItem value="collapsed">Collapsed (icons only)</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
You can always toggle this later using the button in the sidebar
</p>
</div>
{/* Last.fm Scrobbling */}
<div className="grid gap-3">
<Label className="flex items-center gap-2">
@@ -334,31 +296,6 @@ export function LoginForm({
</p>
</div>
{/* Standalone Last.fm */}
<div className="grid gap-3">
<Label className="flex items-center gap-2">
<FaLastfm className="w-4 h-4" />
Standalone Last.fm (Advanced)
</Label>
<Select
value={standaloneLastfmEnabled ? "enabled" : "disabled"}
onValueChange={(value) => setStandaloneLastfmEnabled(value === "enabled")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">Enabled</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{standaloneLastfmEnabled
? "Direct Last.fm API integration (configure in Settings later)"
: "Use only Navidrome's Last.fm integration"}
</p>
</div>
<div className="flex flex-col gap-3">
<Button onClick={handleFinishSetup} className="w-full">
<FaCheck className="w-4 h-4 mr-2" />
@@ -383,7 +320,7 @@ export function LoginForm({
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<Card className="py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaServer className="w-5 h-5" />

View File

@@ -58,7 +58,7 @@ const FavoritesPage = () => {
artistId: song.artistId,
url: api?.getStreamUrl(song.id) || '',
duration: song.duration,
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt) : undefined,
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt, 1200) : undefined,
starred: !!song.starred
});
};
@@ -78,7 +78,7 @@ const FavoritesPage = () => {
artistId: song.artistId,
url: api.getStreamUrl(song.id),
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
starred: !!song.starred
}));
@@ -201,7 +201,7 @@ const FavoritesPage = () => {
<div className="w-12 h-12 relative shrink-0">
{song.coverArt && api ? (
<Image
src={api.getCoverArtUrl(song.coverArt)}
src={api.getCoverArtUrl(song.coverArt, 1200)}
alt={song.album}
fill
className="rounded object-cover"

View File

@@ -88,6 +88,18 @@
body {
font-family: Arial, Helvetica, sans-serif;
}
/* Hide scrollbars on mobile */
@media (max-width: 768px) {
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
}
*::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
}
}
@layer utilities {
@@ -816,34 +828,170 @@
---break---
*/
/*
/* Mobile-specific optimizations */
@media (max-width: 767px) {
/* Improve touch targets for mobile */
button {
min-height: 44px;
min-width: 44px;
}
will delete after the new theme replaces the old one
since the new theme already has the sidebar colors defined
/* Better touch feedback */
button:active {
transform: scale(0.95);
transition: transform 0.1s ease;
}
:root {
--sidebar: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
/* Ensure proper viewport behavior */
html {
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
/* Smooth scrolling for mobile */
.overflow-y-auto {
-webkit-overflow-scrolling: touch;
}
/* Mobile audio player specific */
.mobile-audio-player {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
/* Prevent horizontal scroll */
body {
overflow-x: hidden;
}
}
.dark {
--sidebar: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
} */
/* 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));
}
/* Mobile Audio Player Styles */
.mobile-audio-player {
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.mobile-audio-player button {
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Prevent iOS zoom on input focus */
@media screen and (max-width: 767px) {
input[type="range"] {
font-size: 16px;
}
/* Improve button touch targets */
.mobile-audio-player button {
min-height: 44px;
min-width: 44px;
}
}
/* Better focus states for accessibility */
button:focus-visible {
outline: 2px solid hsl(var(--primary));
outline-offset: 2px;
}
/* Improved animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.3s ease-out;
}
/* Safe area insets for mobile devices */
@supports (padding: max(0px)) {
.mobile-safe-bottom {
padding-bottom: max(1rem, env(safe-area-inset-bottom));
}
.mobile-safe-top {
padding-top: max(0.5rem, env(safe-area-inset-top));
}
}
/* Progress bar improvements for mobile */
@media (max-width: 767px) {
.progress-mobile {
height: 3px;
cursor: pointer;
-webkit-appearance: none;
touch-action: manipulation;
}
.progress-mobile::-webkit-slider-thumb {
-webkit-appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: hsl(var(--primary));
cursor: pointer;
margin-top: -6px;
}
}
/*
---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));
}

View File

@@ -26,6 +26,35 @@ export const metadata = {
'max-snippet': -1,
},
},
viewport: {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
},
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: isDev && shortCommit ? `mice (dev: ${shortCommit})` : 'mice',
},
formatDetection: {
telephone: false,
},
other: {
'apple-mobile-web-app-capable': 'yes',
'apple-mobile-web-app-status-bar-style': 'black-translucent',
'format-detection': 'telephone=no',
},
icons: {
icon: [
{ url: '/favicon.ico', sizes: '48x48' },
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
],
apple: [
{ url: '/apple-touch-icon-precomposed.png', sizes: '180x180', type: 'image/png' },
],
},
};
const geistSans = localFont({

244
app/library/page.tsx Normal file
View File

@@ -0,0 +1,244 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Music, Users, Disc, ListMusic, Heart, Play } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { getNavidromeAPI } from '@/lib/navidrome';
import NavidromeAPI from '@/lib/navidrome';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { useIsMobile } from '@/hooks/use-mobile';
interface Album {
id: string;
name: string;
artist: string;
artistId?: string;
coverArt?: string;
year?: number;
songCount: number;
}
interface LibraryStats {
albums: number;
artists: number;
songs: number;
playlists: number;
}
export default function LibraryPage() {
const [recentAlbums, setRecentAlbums] = useState<Album[]>([]);
const [stats, setStats] = useState<LibraryStats>({ albums: 0, artists: 0, songs: 0, playlists: 0 });
const [loading, setLoading] = useState(true);
const [api, setApi] = useState<NavidromeAPI | null>(null);
const { playAlbum } = useAudioPlayer();
const isMobile = useIsMobile();
useEffect(() => {
const loadLibraryData = async () => {
try {
const navidromeApi = getNavidromeAPI();
if (!navidromeApi) {
console.error('Navidrome API not available');
return;
}
setApi(navidromeApi);
// Load recent albums
const albumsData = await navidromeApi.getAlbums('newest', 4, 0);
setRecentAlbums(albumsData || []);
// Load library stats
const [allAlbums, allArtists, allPlaylists] = await Promise.all([
navidromeApi.getAlbums('alphabeticalByName', 1, 0), // Just to get count
navidromeApi.getArtists(),
navidromeApi.getPlaylists()
]);
setStats({
albums: allAlbums?.length || 0,
artists: allArtists?.length || 0,
songs: 0, // We don't have a direct method for this
playlists: allPlaylists?.length || 0
});
} catch (error) {
console.error('Failed to load library data:', error);
} finally {
setLoading(false);
}
};
loadLibraryData();
}, []);
const handlePlayAlbum = async (album: Album) => {
try {
await playAlbum(album.id);
} catch (error) {
console.error('Failed to play album:', error);
}
};
const libraryLinks = [
{
href: '/library/albums',
label: 'Albums',
icon: Disc,
description: 'Browse all albums',
count: stats.albums
},
{
href: '/library/artists',
label: 'Artists',
icon: Users,
description: 'Discover artists',
count: stats.artists
},
{
href: '/library/songs',
label: 'Songs',
icon: Music,
description: 'All your music',
count: stats.songs
},
{
href: '/library/playlists',
label: 'Playlists',
icon: ListMusic,
description: 'Your playlists',
count: stats.playlists
},
{
href: '/favorites',
label: 'Favorites',
icon: Heart,
description: 'Starred music',
count: 0
}
];
if (loading) {
return (
<div className="p-4 pb-20 space-y-6">
<div className="space-y-4">
<h1 className="text-2xl font-bold">Your Library</h1>
{/* Loading skeleton for library links */}
<div>
<h2 className="text-lg font-semibold mb-3">Browse</h2>
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="bg-muted rounded-lg h-16"></div>
</div>
))}
</div>
</div>
{/* Loading skeleton for recent albums */}
<div>
<h2 className="text-lg font-semibold mb-3">Recently Added</h2>
<div className="grid grid-cols-2 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="bg-muted rounded-lg aspect-square mb-2"></div>
<div className="bg-muted h-4 rounded mb-1"></div>
<div className="bg-muted h-3 rounded w-3/4"></div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
return (
<div className="p-4 pb-20 space-y-6">
<div className="space-y-4">
<h1 className="text-2xl font-bold">Your Library</h1>
{/* Library Navigation - Always at top */}
<div>
{/* <h2 className="text-lg font-semibold mb-3">Browse</h2> */}
<div className="flex flex-col gap-2">
{libraryLinks.map((link) => {
const Icon = link.icon;
return (
<Link key={link.href} href={link.href}>
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
<CardContent className="p-2">
<div className="flex items-center space-x-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Icon className="w-6 h-6 text-primary" />
</div>
<div className="flex-1">
<h3 className="font-medium">{link.label}</h3>
<p className="text-sm text-muted-foreground">{link.description}</p>
</div>
{link.count > 0 && (
<div className="text-sm text-muted-foreground">
{link.count}
</div>
)}
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
</div>
{/* Recently Added Albums - At bottom on mobile, after Browse on desktop */}
<div>
<h2 className="text-lg font-semibold mb-3">Recently Added</h2>
<div className="grid grid-cols-2 gap-4">
{recentAlbums.map((album) => (
<Card key={album.id} className="group cursor-pointer hover:bg-muted/50 transition-colors">
<CardContent className="p-3">
<Link href={`/album/${album.id}`}>
<div className="relative aspect-square mb-2">
<Image
src={album.coverArt && api ? api.getCoverArtUrl(album.coverArt, 300) : '/default-user.jpg'}
alt={album.name}
width={600}
height={600}
className="object-cover rounded-lg"
sizes="(max-width: 768px) 50vw, 200px"
/>
{!isMobile && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handlePlayAlbum(album);
}}
className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center"
>
<Play className="w-8 h-8 text-white fill-white" />
</button>
)}
</div>
<h3 className="font-medium text-sm truncate hover:underline">{album.name}</h3>
<Link
href={`/artist/${album.artistId || album.artist}`}
className="text-xs text-muted-foreground truncate hover:underline block"
onClick={(e) => e.stopPropagation()}
>
{album.artist}
</Link>
{/* {album.year && (
<p className="text-xs text-muted-foreground">{album.year}</p>
)} */}
</Link>
</CardContent>
</Card>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -53,7 +53,7 @@ const PlaylistsPage: React.FC = () => {
<ScrollArea>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4"> {playlists.map((playlist) => {
const playlistCoverUrl = playlist.coverArt && api
? api.getCoverArtUrl(playlist.coverArt, 200)
? api.getCoverArtUrl(playlist.coverArt, 600)
: '/default-user.jpg';
return (

View File

@@ -101,7 +101,7 @@ export default function SongsPage() {
setFilteredSongs(filtered);
}, [songs, searchQuery, sortBy, sortDirection]);
const handlePlaySong = (song: Song) => {
const handlePlayClick = (song: Song) => {
if (!api) {
console.error('Navidrome API not available');
return;
@@ -114,7 +114,7 @@ export default function SongsPage() {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
@@ -135,7 +135,7 @@ export default function SongsPage() {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
@@ -222,7 +222,7 @@ export default function SongsPage() {
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors ${
isCurrentlyPlaying(song) ? 'bg-accent/50 border-l-4 border-primary' : ''
}`}
onClick={() => handlePlaySong(song)}
onClick={() => handlePlayClick(song)}
>
{/* Track Number / Play Indicator */}
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
@@ -240,7 +240,7 @@ export default function SongsPage() {
{/* Album Art */}
<div className="w-12 h-12 mr-4 shrink-0"> <Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 100) : '/default-user.jpg'}
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 48) : '/default-user.jpg'}
alt={song.album}
width={48}
height={48}

View File

@@ -38,6 +38,25 @@ export default function manifest(): MetadataRoute.Manifest {
type: 'image/png',
sizes: '512x512',
purpose: 'maskable'
},
// Apple Touch Icons for iOS
{
src: '/apple-touch-icon.png',
type: 'image/png',
sizes: '180x180',
purpose: 'any'
},
{
src: '/icon-192.png',
type: 'image/png',
sizes: '152x152',
purpose: 'any'
},
{
src: '/icon-192.png',
type: 'image/png',
sizes: '120x120',
purpose: 'any'
}
],
screenshots: [

View File

@@ -12,6 +12,8 @@ import { useSearchParams } from 'next/navigation';
import { useAudioPlayer } from './components/AudioPlayerContext';
import { SongRecommendations } from './components/SongRecommendations';
import { Skeleton } from '@/components/ui/skeleton';
import { useIsMobile } from '@/hooks/use-mobile';
import { UserProfile } from './components/UserProfile';
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
@@ -24,6 +26,7 @@ function MusicPageContent() {
const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]);
const [favoritesLoading, setFavoritesLoading] = useState(true);
const [shortcutProcessed, setShortcutProcessed] = useState(false);
const isMobile = useIsMobile();
useEffect(() => {
if (albums.length > 0) {

View File

@@ -57,7 +57,7 @@ export default function PlaylistPage() {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
@@ -77,7 +77,7 @@ export default function PlaylistPage() {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
@@ -98,7 +98,7 @@ export default function PlaylistPage() {
artist: song.artist,
album: song.album,
duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
albumId: song.albumId,
artistId: song.artistId,
starred: !!song.starred
@@ -209,7 +209,7 @@ export default function PlaylistPage() {
{/* Album Art */}
<div className="w-12 h-12 mr-4 shrink-0"> <Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 100) : '/default-user.jpg'}
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 48) : '/default-user.jpg'}
alt={song.album}
width={48}
height={48}

View File

@@ -353,7 +353,7 @@ const SettingsPage = () => {
style={{ columnFill: 'balance' }}>
{!hasEnvConfig && (
<Card className="mb-6 break-inside-avoid">
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaServer className="w-5 h-5" />
@@ -442,7 +442,7 @@ const SettingsPage = () => {
)}
{hasEnvConfig && (
<Card className="mb-6 break-inside-avoid">
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaServer className="w-5 h-5" />
@@ -469,7 +469,7 @@ const SettingsPage = () => {
</Card>
)}
<Card className="mb-6 break-inside-avoid">
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaLastfm className="w-5 h-5" />
@@ -547,7 +547,7 @@ const SettingsPage = () => {
</CardContent>
</Card> */}
<Card className="mb-6 break-inside-avoid">
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="w-5 h-5" />
@@ -602,7 +602,7 @@ const SettingsPage = () => {
</CardContent>
</Card>
<Card className="mb-6 break-inside-avoid">
{/* <Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaLastfm className="w-5 h-5" />
@@ -695,7 +695,7 @@ const SettingsPage = () => {
</>
)}
</CardContent>
</Card>
</Card> */}
{/* Sidebar Customization */}
<div className="break-inside-avoid mb-6">
@@ -712,7 +712,7 @@ const SettingsPage = () => {
<CacheManagement />
</div>
<Card className="mb-6 break-inside-avoid">
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>
@@ -761,7 +761,7 @@ const SettingsPage = () => {
</Card>
{/* Theme Preview */}
<Card className="mb-6 break-inside-avoid">
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle>Preview</CardTitle>
<CardDescription>
@@ -789,6 +789,47 @@ const SettingsPage = () => {
</div>
</CardContent>
</Card>
{/* Debug Section - Development Only */}
{process.env.NODE_ENV === 'development' && (
<Card className="mb-6 break-inside-avoid py-5 border-orange-200 bg-orange-50/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-orange-700">
<Settings className="w-5 h-5" />
Debug Tools
</CardTitle>
<CardDescription className="text-orange-600">
Development-only debugging utilities
</CardDescription>
</CardHeader>
<CardContent>
<Button
onClick={() => {
// Save Navidrome config before clearing
const navidromeConfig = localStorage.getItem('navidrome-config');
// Clear all localStorage
localStorage.clear();
// Restore Navidrome config
if (navidromeConfig) {
localStorage.setItem('navidrome-config', navidromeConfig);
}
// Reload page to reset state
window.location.reload();
}}
variant="outline"
className="w-full bg-orange-100 border-orange-300 text-orange-700 hover:bg-orange-200"
>
Clear All Data (Keep Navidrome Config)
</Button>
<p className="text-xs text-orange-600 mt-2">
This will clear all localStorage data except your Navidrome server configuration, then reload the page.
</p>
</CardContent>
</Card>
)}
</div>
</div>
)}

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-0 shadow-sm",
className
)}
{...props}

View File

@@ -15,6 +15,3 @@ printenv | grep NEXT_PUBLIC_ | while read -r line ; do
done
echo "✅ Environment variable replacement complete"
# Execute the container's main process (CMD in Dockerfile)
exec "$@"

View File

@@ -0,0 +1,96 @@
import { useState, useEffect, useRef } from 'react';
interface UseResponsiveImageSizeOptions {
/** Minimum size threshold */
minSize?: number;
/** Maximum size threshold */
maxSize?: number;
/** Multiplier for high DPI displays */
dpiMultiplier?: number;
/** Available size tiers from Navidrome */
availableSizes?: number[];
}
/**
* Hook to calculate optimal image size based on container dimensions
*/
export function useResponsiveImageSize(options: UseResponsiveImageSizeOptions = {}) {
const {
minSize = 60,
maxSize = 1200,
dpiMultiplier = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1,
availableSizes = [60, 120, 240, 400, 600, 1200] // Clean divisions of 1200
} = options;
const containerRef = useRef<HTMLElement>(null);
const [imageSize, setImageSize] = useState<number>(300); // Default fallback
useEffect(() => {
const calculateOptimalSize = () => {
if (!containerRef.current) return;
const element = containerRef.current;
const rect = element.getBoundingClientRect();
// Use the larger dimension (width or height) as base
const displaySize = Math.max(rect.width, rect.height);
// Account for device pixel ratio for crisp images on high DPI displays
const targetSize = Math.round(displaySize * dpiMultiplier);
// Clamp to min/max bounds
const clampedSize = Math.max(minSize, Math.min(maxSize, targetSize));
// Find the next larger available size to ensure quality
const optimalSize = availableSizes.find(size => size >= clampedSize) || availableSizes[availableSizes.length - 1];
setImageSize(optimalSize);
};
// Calculate initial size
calculateOptimalSize();
// Recalculate on resize
const resizeObserver = new ResizeObserver(calculateOptimalSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, [minSize, maxSize, dpiMultiplier, availableSizes]);
return {
containerRef,
imageSize,
/** Get size for a specific display dimension */
getSizeForDimension: (dimension: number) => {
const targetSize = Math.round(dimension * dpiMultiplier);
const clampedSize = Math.max(minSize, Math.min(maxSize, targetSize));
return availableSizes.find(size => size >= clampedSize) || availableSizes[availableSizes.length - 1];
}
};
}
/**
* Simple function to get optimal image size for known dimensions
*/
export function getOptimalImageSize(
displayWidth: number,
displayHeight: number,
options: Omit<UseResponsiveImageSizeOptions, 'availableSizes'> & { availableSizes?: number[] } = {}
): number {
const {
minSize = 60,
maxSize = 1200,
dpiMultiplier = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
availableSizes = [60, 120, 240, 400, 600, 1200] // Clean divisions of 1200
} = options;
const displaySize = Math.max(displayWidth, displayHeight);
const targetSize = Math.round(displaySize * dpiMultiplier);
const clampedSize = Math.max(minSize, Math.min(maxSize, targetSize));
return availableSizes.find(size => size >= clampedSize) || availableSizes[availableSizes.length - 1];
}

38
lib/gravatar.ts Normal file
View File

@@ -0,0 +1,38 @@
import crypto from 'crypto';
/**
* Generate a Gravatar URL from an email address
* @param email - The email address
* @param size - The size of the image (default: 80)
* @param defaultImage - Default image type if no Gravatar found (default: 'identicon')
* @returns The Gravatar URL
*/
export function getGravatarUrl(
email: string,
size: number = 80,
defaultImage: string = 'identicon'
): string {
// Normalize email: trim whitespace and convert to lowercase
const normalizedEmail = email.trim().toLowerCase();
// Generate MD5 hash of the email
const hash = crypto.createHash('md5').update(normalizedEmail).digest('hex');
// Construct the Gravatar URL
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=${defaultImage}`;
}
/**
* Generate a Gravatar URL with retina support (2x size)
* @param email - The email address
* @param size - The base size of the image
* @param defaultImage - Default image type if no Gravatar found
* @returns The Gravatar URL at 2x resolution
*/
export function getGravatarUrlRetina(
email: string,
size: number = 80,
defaultImage: string = 'identicon'
): string {
return getGravatarUrl(email, size * 2, defaultImage);
}

125
lib/image-utils.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* Utility functions for calculating optimal image sizes for different contexts
*/
export interface ImageSizeContext {
/** The display width in CSS pixels */
displayWidth: number;
/** The display height in CSS pixels */
displayHeight: number;
/** Device pixel ratio for high-DPI displays */
devicePixelRatio?: number;
/** Additional scaling factor (e.g., for hover effects) */
scaleFactor?: number;
}
/**
* Calculate the optimal image size for the given context
* Takes into account device pixel ratio and potential scaling effects
*/
export function calculateOptimalImageSize(context: ImageSizeContext): number {
const { displayWidth, displayHeight, devicePixelRatio = 1, scaleFactor = 1.1 } = context;
// Use the larger dimension to ensure we cover the entire display area
const baseDimension = Math.max(displayWidth, displayHeight);
// Account for device pixel ratio and potential scaling
const optimalSize = Math.ceil(baseDimension * devicePixelRatio * scaleFactor);
// Cap at reasonable maximum to avoid excessive bandwidth usage
return Math.min(optimalSize, 1200);
}
/**
* Get optimal image size for common component contexts
* All sizes are clean divisions of 1200 for optimal scaling
*/
export const ImageSizes = {
// Small thumbnails in lists - 1200/20 = 60, rounded to 64 for better display
THUMBNAIL: 60,
// Small album covers in compact views - 1200/10 = 120
SMALL_ALBUM: 120,
// Medium album covers in grid views - 1200/5 = 240
MEDIUM_ALBUM: 240,
// Large album covers in detail views - 1200/3 = 400
LARGE_ALBUM: 400,
// Extra large for full-screen displays - 1200/2 = 600
XLARGE_ALBUM: 600,
// Full resolution - 1200/1 = 1200
FULL_ALBUM: 1200,
// Artist images
ARTIST_SMALL: 120, // 1200/10
ARTIST_MEDIUM: 240, // 1200/5
ARTIST_LARGE: 400, // 1200/3
// Player images
PLAYER_MINI: 60, // 1200/20
PLAYER_COMPACT: 120, // 1200/10
PLAYER_FULL: 400, // 1200/3
} as const;
/**
* Get responsive image size based on container and viewport
*/
export function getResponsiveImageSize(
containerWidth: number,
viewportWidth: number = typeof window !== 'undefined' ? window?.innerWidth || 1920 : 1920,
devicePixelRatio: number = typeof window !== 'undefined' ? window?.devicePixelRatio || 1 : 1
): number {
let targetSize: number;
// Determine base size based on container and viewport
// All sizes are clean divisions of 1200
if (containerWidth <= 60) {
targetSize = ImageSizes.THUMBNAIL; // 60px
} else if (containerWidth <= 120) {
targetSize = ImageSizes.SMALL_ALBUM; // 120px
} else if (containerWidth <= 240 || viewportWidth <= 768) {
targetSize = ImageSizes.MEDIUM_ALBUM; // 240px
} else if (containerWidth <= 400 || viewportWidth <= 1024) {
targetSize = ImageSizes.LARGE_ALBUM; // 400px
} else if (containerWidth <= 600 || viewportWidth <= 1440) {
targetSize = ImageSizes.XLARGE_ALBUM; // 600px
} else {
targetSize = ImageSizes.FULL_ALBUM; // 1200px
}
// Apply device pixel ratio but ensure we stay within clean divisions of 1200
const scaledSize = Math.ceil(targetSize * devicePixelRatio);
// Round to nearest clean division of 1200
const divisions = [60, 120, 240, 400, 600, 1200];
return divisions.find(size => size >= scaledSize) || 1200;
}
/**
* Hook to get optimal image size for a container
* Returns clean divisions of 1200 for optimal scaling
*/
export function useOptimalImageSize(
width: number,
height: number = width,
scaleFactor: number = 1.1
): number {
if (typeof window === 'undefined') {
// SSR fallback - return appropriate size based on dimensions
return getResponsiveImageSize(width, 1920, 1);
}
const optimalSize = calculateOptimalImageSize({
displayWidth: width,
displayHeight: height,
devicePixelRatio: window.devicePixelRatio || 1,
scaleFactor,
});
// Round to nearest clean division of 1200
const divisions = [60, 120, 240, 400, 600, 1200];
return divisions.find(size => size >= optimalSize) || 1200;
}

View File

@@ -110,6 +110,26 @@ export interface ArtistInfo {
similarArtist?: Artist[];
}
export interface User {
username: string;
email?: string;
scrobblingEnabled: boolean;
maxBitRate?: number;
adminRole: boolean;
settingsRole: boolean;
downloadRole: boolean;
uploadRole: boolean;
playlistRole: boolean;
coverArtRole: boolean;
commentRole: boolean;
podcastRole: boolean;
streamRole: boolean;
jukeboxRole: boolean;
shareRole: boolean;
videoConversionRole: boolean;
avatarLastChanged?: string;
}
class NavidromeAPI {
private config: NavidromeConfig;
private clientName = 'miceclient';
@@ -171,6 +191,12 @@ class NavidromeAPI {
}
}
async getUserInfo(): Promise<User> {
const response = await this.makeRequest('getUser', { username: this.config.username });
const userData = response.user as User;
return userData;
}
async getArtists(): Promise<Artist[]> {
const response = await this.makeRequest('getArtists');
const artists: Artist[] = [];

View File

@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
qualities: [50, 75, 100],
remotePatterns: [
{
protocol: "https",

View File

@@ -52,7 +52,7 @@
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.525.0",
"next": "15.3.4",
"next": "15.4.4",
"next-themes": "^0.4.6",
"posthog-js": "^1.255.0",
"posthog-node": "^5.1.1",
@@ -75,15 +75,26 @@
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"chalk": "^5.3.0",
"eslint": "^9.31",
"eslint": "^9.32",
"eslint-config-next": "15.4.4",
"postcss": "^8",
"tailwindcss": "^4.1.11",
"typescript": "^5"
},
"packageManager": "pnpm@10.12.4",
"packageManager": "pnpm@10.13.1",
"overrides": {
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6"
},
"pnpm": {
"overrides": {
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6"
},
"onlyBuiltDependencies": [
"sharp",
"unrs-resolver"
]
}
}

580
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- unrs-resolver

Binary file not shown.

Before

Width:  |  Height:  |  Size: 869 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 339 KiB