0
app/api/test-navidrome/route.ts
Normal file
0
app/api/test-navidrome/route.ts
Normal file
@@ -21,7 +21,7 @@ export default function ArtistPage() {
|
|||||||
const [artist, setArtist] = useState<Artist | null>(null);
|
const [artist, setArtist] = useState<Artist | null>(null);
|
||||||
const [isPlayingArtist, setIsPlayingArtist] = useState(false);
|
const [isPlayingArtist, setIsPlayingArtist] = useState(false);
|
||||||
const { getArtist, starItem, unstarItem } = useNavidrome();
|
const { getArtist, starItem, unstarItem } = useNavidrome();
|
||||||
const { addArtistToQueue, playAlbum, clearQueue } = useAudioPlayer();
|
const { playArtist } = useAudioPlayer();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const api = getNavidromeAPI();
|
const api = getNavidromeAPI();
|
||||||
|
|
||||||
@@ -65,19 +65,7 @@ export default function ArtistPage() {
|
|||||||
|
|
||||||
setIsPlayingArtist(true);
|
setIsPlayingArtist(true);
|
||||||
try {
|
try {
|
||||||
// Clear current queue and add all artist albums
|
await playArtist(artist.id);
|
||||||
clearQueue();
|
|
||||||
await addArtistToQueue(artist.id);
|
|
||||||
|
|
||||||
// Start playing the first album if we have any
|
|
||||||
if (artistAlbums.length > 0) {
|
|
||||||
await playAlbum(artistAlbums[0].id);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Playing Artist",
|
|
||||||
description: `Now playing all albums by ${artist.name}`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to play artist:', error);
|
console.error('Failed to play artist:', error);
|
||||||
toast({
|
toast({
|
||||||
@@ -108,36 +96,42 @@ export default function ArtistPage() {
|
|||||||
: '/default-user.jpg';
|
: '/default-user.jpg';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full px-4 py-6 lg:px-8">
|
<div className="h-full px-4 py-6 lg:px-8 pb-24">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Artist Header */}
|
{/* Artist Header */}
|
||||||
<div className="relative bg-gradient-to-r from-blue-900 to-purple-900 rounded-lg p-8">
|
<div className="relative rounded-lg p-8">
|
||||||
<div className="flex items-center space-x-6">
|
<div className="relative rounded-sm p-10">
|
||||||
<div className="relative">
|
<div
|
||||||
<Image
|
className="absolute inset-0 bg-center bg-cover bg-no-repeat blur-xl"
|
||||||
src={artistImageUrl}
|
style={{ backgroundImage: `url(${artistImageUrl})` }}
|
||||||
alt={artist.name}
|
/>
|
||||||
width={120}
|
<div className="relative z-10 flex items-center space-x-6">
|
||||||
height={120}
|
<div className="relative">
|
||||||
className="rounded-full border-4 border-white shadow-lg"
|
<Image
|
||||||
/>
|
src={artistImageUrl}
|
||||||
</div>
|
alt={artist.name}
|
||||||
<div className="flex-1">
|
width={120}
|
||||||
<h1 className="text-4xl font-bold text-white mb-2">{artist.name}</h1>
|
height={120}
|
||||||
<p className="text-white/80 mb-4">{artist.albumCount} albums</p>
|
className="rounded-full shadow-lg"
|
||||||
<div className="flex gap-3">
|
/>
|
||||||
<Button
|
</div>
|
||||||
onClick={handlePlayArtist}
|
<div className="flex-1">
|
||||||
disabled={isPlayingArtist}
|
<h1 className="text-4xl font-bold text-white mb-2">{artist.name}</h1>
|
||||||
className="bg-green-600 hover:bg-green-700"
|
<p className="text-white/80 mb-4">{artist.albumCount} albums</p>
|
||||||
>
|
<div className="flex gap-3">
|
||||||
<Play className="w-4 h-4 mr-2" />
|
<Button
|
||||||
{isPlayingArtist ? 'Adding to Queue...' : 'Play Artist'}
|
onClick={handlePlayArtist}
|
||||||
</Button>
|
disabled={isPlayingArtist}
|
||||||
<Button onClick={handleStar} variant="secondary">
|
className="bg-primary hover:bg-primary/70"
|
||||||
<Heart className={isStarred ? 'text-red-500' : 'text-gray-500'} fill={isStarred ? 'red' : 'none'}/>
|
>
|
||||||
{isStarred ? 'Starred' : 'Star Artist'}
|
<Play className="w-4 h-4 mr-2" />
|
||||||
</Button>
|
{isPlayingArtist ? 'Adding to Queue...' : 'Play Artist'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleStar} variant="secondary">
|
||||||
|
<Heart className={isStarred ? 'text-red-500' : 'text-gray-500'} fill={isStarred ? 'red' : 'none'} />
|
||||||
|
{isStarred ? 'Starred' : 'Star Artist'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,86 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tabs, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsContent } from '@/components/ui/tabs';
|
||||||
import { AlbumArtwork } from '@/app/components/album-artwork';
|
import { AlbumArtwork } from '@/app/components/album-artwork';
|
||||||
import { ArtistIcon } from '@/app/components/artist-icon';
|
import { ArtistIcon } from '@/app/components/artist-icon';
|
||||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||||
|
import { getNavidromeAPI, Album } from '@/lib/navidrome';
|
||||||
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||||
|
import { Shuffle } from 'lucide-react';
|
||||||
import Loading from '@/app/components/loading';
|
import Loading from '@/app/components/loading';
|
||||||
|
|
||||||
export default function BrowsePage() {
|
export default function BrowsePage() {
|
||||||
const { albums, artists, isLoading } = useNavidrome();
|
const { artists, isLoading: contextLoading } = useNavidrome();
|
||||||
|
const { shuffleAllAlbums } = useAudioPlayer();
|
||||||
|
const [albums, setAlbums] = useState<Album[]>([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
|
const [isLoadingAlbums, setIsLoadingAlbums] = useState(false);
|
||||||
|
const [hasMoreAlbums, setHasMoreAlbums] = useState(true);
|
||||||
|
const albumsPerPage = 84;
|
||||||
|
|
||||||
if (isLoading) {
|
const api = getNavidromeAPI();
|
||||||
|
|
||||||
|
const loadAlbums = async (page: number, append: boolean = false) => {
|
||||||
|
try {
|
||||||
|
setIsLoadingAlbums(true);
|
||||||
|
const offset = page * albumsPerPage;
|
||||||
|
|
||||||
|
// Use alphabeticalByName to get all albums in alphabetical order
|
||||||
|
const newAlbums = await api.getAlbums('alphabeticalByName', albumsPerPage, offset);
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
setAlbums(prev => [...prev, ...newAlbums]);
|
||||||
|
} else {
|
||||||
|
setAlbums(newAlbums);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got fewer albums than requested, we've reached the end
|
||||||
|
setHasMoreAlbums(newAlbums.length === albumsPerPage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load albums:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingAlbums(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAlbums(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Infinite scroll handler
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = (e: Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target || isLoadingAlbums || !hasMoreAlbums) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = target;
|
||||||
|
const threshold = 200; // Load more when 200px from bottom
|
||||||
|
|
||||||
|
if (scrollHeight - scrollTop - clientHeight < threshold) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollArea = document.querySelector('[data-radix-scroll-area-viewport]');
|
||||||
|
if (scrollArea) {
|
||||||
|
scrollArea.addEventListener('scroll', handleScroll);
|
||||||
|
return () => scrollArea.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
}, [isLoadingAlbums, hasMoreAlbums, currentPage]);
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
if (isLoadingAlbums || !hasMoreAlbums) return;
|
||||||
|
const nextPage = currentPage + 1;
|
||||||
|
setCurrentPage(nextPage);
|
||||||
|
loadAlbums(nextPage, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (contextLoading) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +89,7 @@ export default function BrowsePage() {
|
|||||||
<>
|
<>
|
||||||
<Tabs defaultValue="music" className="h-full flex flex-col space-y-6">
|
<Tabs defaultValue="music" className="h-full flex flex-col space-y-6">
|
||||||
<TabsContent value="music" className="border-none p-0 outline-none flex flex-col flex-grow">
|
<TabsContent value="music" className="border-none p-0 outline-none flex flex-col flex-grow">
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-2xl font-semibold tracking-tight">
|
<p className="text-2xl font-semibold tracking-tight">
|
||||||
@@ -30,6 +99,10 @@ export default function BrowsePage() {
|
|||||||
the people who make the music
|
the people who make the music
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={shuffleAllAlbums} className="flex items-center gap-2">
|
||||||
|
<Shuffle className="w-4 h-4" />
|
||||||
|
Shuffle All Albums
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<div className="relative flex-grow">
|
<div className="relative flex-grow">
|
||||||
@@ -52,10 +125,10 @@ export default function BrowsePage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-2xl font-semibold tracking-tight">
|
<p className="text-2xl font-semibold tracking-tight">
|
||||||
Browse
|
Browse Albums
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Browse the full collection of music available.
|
Browse the full collection of albums ({albums.length} loaded).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,20 +136,38 @@ export default function BrowsePage() {
|
|||||||
<div className="relative flex-grow">
|
<div className="relative flex-grow">
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="flex flex-wrap gap-4 p-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4 p-4 pb-8">
|
||||||
{albums.map((album) => (
|
{albums.map((album) => (
|
||||||
<AlbumArtwork
|
<AlbumArtwork
|
||||||
key={album.id}
|
key={album.id}
|
||||||
album={album}
|
album={album}
|
||||||
className="w-[230px]"
|
className="w-full"
|
||||||
aspectRatio="square"
|
aspectRatio="square"
|
||||||
width={230}
|
width={200}
|
||||||
height={230}
|
height={200}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{hasMoreAlbums && (
|
||||||
|
<div className="flex justify-center p-4 pb-24">
|
||||||
|
<Button
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={isLoadingAlbums}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{isLoadingAlbums ? 'Loading...' : `Load More Albums (${albumsPerPage} more)`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!hasMoreAlbums && albums.length > 0 && (
|
||||||
|
<div className="flex justify-center p-4 pb-24">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
All albums loaded ({albums.length} total)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ScrollBar orientation="horizontal" />
|
<ScrollBar orientation="vertical" />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -84,8 +84,17 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
localStorage.removeItem('navidrome-current-track-time');
|
localStorage.removeItem('navidrome-current-track-time');
|
||||||
}
|
}
|
||||||
|
|
||||||
audioCurrent.play();
|
// Auto-play only if the track has the autoPlay flag
|
||||||
setIsPlaying(true);
|
if (currentTrack.autoPlay) {
|
||||||
|
audioCurrent.play().then(() => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Failed to auto-play:', error);
|
||||||
|
setIsPlaying(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [currentTrack]);
|
}, [currentTrack]);
|
||||||
|
|
||||||
@@ -122,10 +131,20 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
if (audioCurrent) {
|
if (audioCurrent) {
|
||||||
audioCurrent.addEventListener('timeupdate', updateProgress);
|
audioCurrent.addEventListener('timeupdate', updateProgress);
|
||||||
audioCurrent.addEventListener('ended', handleTrackEnd);
|
audioCurrent.addEventListener('ended', handleTrackEnd);
|
||||||
audioCurrent.addEventListener('seeked', handleSeeked);
|
audioCurrent.addEventListener('seeked', handleSeeked);
|
||||||
|
audioCurrent.addEventListener('play', handlePlay);
|
||||||
|
audioCurrent.addEventListener('pause', handlePause);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -133,6 +152,8 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
audioCurrent.removeEventListener('timeupdate', updateProgress);
|
audioCurrent.removeEventListener('timeupdate', updateProgress);
|
||||||
audioCurrent.removeEventListener('ended', handleTrackEnd);
|
audioCurrent.removeEventListener('ended', handleTrackEnd);
|
||||||
audioCurrent.removeEventListener('seeked', handleSeeked);
|
audioCurrent.removeEventListener('seeked', handleSeeked);
|
||||||
|
audioCurrent.removeEventListener('play', handlePlay);
|
||||||
|
audioCurrent.removeEventListener('pause', handlePause);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [playNextTrack, currentTrack]);
|
}, [playNextTrack, currentTrack]);
|
||||||
@@ -213,10 +234,15 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
if (audioCurrent) {
|
if (audioCurrent) {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
audioCurrent.pause();
|
audioCurrent.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
} else {
|
} else {
|
||||||
audioCurrent.play();
|
audioCurrent.play().then(() => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Failed to play audio:', error);
|
||||||
|
setIsPlaying(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ interface Track {
|
|||||||
coverArt?: string;
|
coverArt?: string;
|
||||||
albumId: string;
|
albumId: string;
|
||||||
artistId: string;
|
artistId: string;
|
||||||
|
autoPlay?: boolean; // Flag to control auto-play
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AudioPlayerContextProps {
|
interface AudioPlayerContextProps {
|
||||||
currentTrack: Track | null;
|
currentTrack: Track | null;
|
||||||
playTrack: (track: Track) => void;
|
playTrack: (track: Track, autoPlay?: boolean) => void;
|
||||||
queue: Track[];
|
queue: Track[];
|
||||||
addToQueue: (track: Track) => void;
|
addToQueue: (track: Track) => void;
|
||||||
playNextTrack: () => void;
|
playNextTrack: () => void;
|
||||||
@@ -32,6 +33,10 @@ interface AudioPlayerContextProps {
|
|||||||
addArtistToQueue: (artistId: string) => Promise<void>;
|
addArtistToQueue: (artistId: string) => Promise<void>;
|
||||||
playPreviousTrack: () => void;
|
playPreviousTrack: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
shuffle: boolean;
|
||||||
|
toggleShuffle: () => void;
|
||||||
|
shuffleAllAlbums: () => Promise<void>;
|
||||||
|
playArtist: (artistId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AudioPlayerContext = createContext<AudioPlayerContextProps | undefined>(undefined);
|
const AudioPlayerContext = createContext<AudioPlayerContextProps | undefined>(undefined);
|
||||||
@@ -41,6 +46,7 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
const [queue, setQueue] = useState<Track[]>([]);
|
const [queue, setQueue] = useState<Track[]>([]);
|
||||||
const [playedTracks, setPlayedTracks] = useState<Track[]>([]);
|
const [playedTracks, setPlayedTracks] = useState<Track[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [shuffle, setShuffle] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const api = useMemo(() => getNavidromeAPI(), []);
|
const api = useMemo(() => getNavidromeAPI(), []);
|
||||||
|
|
||||||
@@ -92,14 +98,17 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
};
|
};
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
const playTrack = useCallback((track: Track) => {
|
const playTrack = useCallback((track: Track, autoPlay: boolean = false) => {
|
||||||
// Clear saved timestamp when manually playing a track
|
// Clear saved timestamp when manually playing a track
|
||||||
localStorage.removeItem('navidrome-current-track-time');
|
localStorage.removeItem('navidrome-current-track-time');
|
||||||
|
|
||||||
if (currentTrack) {
|
if (currentTrack) {
|
||||||
setPlayedTracks((prev) => [...prev, currentTrack]);
|
setPlayedTracks((prev) => [...prev, currentTrack]);
|
||||||
}
|
}
|
||||||
setCurrentTrack(track);
|
|
||||||
|
// Set autoPlay flag on the track
|
||||||
|
const trackWithAutoPlay = { ...track, autoPlay };
|
||||||
|
setCurrentTrack(trackWithAutoPlay);
|
||||||
|
|
||||||
// Scrobble the track
|
// Scrobble the track
|
||||||
api.scrobble(track.id).catch(error => {
|
api.scrobble(track.id).catch(error => {
|
||||||
@@ -108,8 +117,19 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
}, [currentTrack, api]);
|
}, [currentTrack, api]);
|
||||||
|
|
||||||
const addToQueue = useCallback((track: Track) => {
|
const addToQueue = useCallback((track: Track) => {
|
||||||
setQueue((prevQueue) => [...prevQueue, track]);
|
setQueue((prevQueue) => {
|
||||||
}, []);
|
if (shuffle && prevQueue.length > 0) {
|
||||||
|
// If shuffle is enabled, insert the track at a random position
|
||||||
|
const randomIndex = Math.floor(Math.random() * (prevQueue.length + 1));
|
||||||
|
const newQueue = [...prevQueue];
|
||||||
|
newQueue.splice(randomIndex, 0, track);
|
||||||
|
return newQueue;
|
||||||
|
} else {
|
||||||
|
// Normal behavior: add to the end
|
||||||
|
return [...prevQueue, track];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [shuffle]);
|
||||||
|
|
||||||
const clearQueue = useCallback(() => {
|
const clearQueue = useCallback(() => {
|
||||||
setQueue([]);
|
setQueue([]);
|
||||||
@@ -124,9 +144,11 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
localStorage.removeItem('navidrome-current-track-time');
|
localStorage.removeItem('navidrome-current-track-time');
|
||||||
|
|
||||||
if (queue.length > 0) {
|
if (queue.length > 0) {
|
||||||
|
// Always pick the first track from the queue
|
||||||
|
// If shuffle is enabled, the queue will already be shuffled
|
||||||
const nextTrack = queue[0];
|
const nextTrack = queue[0];
|
||||||
setQueue((prevQueue) => prevQueue.slice(1));
|
setQueue((prevQueue) => prevQueue.slice(1));
|
||||||
playTrack(nextTrack);
|
playTrack(nextTrack, true); // Auto-play next track
|
||||||
}
|
}
|
||||||
}, [queue, playTrack]);
|
}, [queue, playTrack]);
|
||||||
|
|
||||||
@@ -143,16 +165,38 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
setQueue((prevQueue) => [currentTrack, ...prevQueue]);
|
setQueue((prevQueue) => [currentTrack, ...prevQueue]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentTrack(previousTrack);
|
playTrack(previousTrack, true); // Auto-play previous track
|
||||||
}
|
}
|
||||||
}, [playedTracks, currentTrack]);
|
}, [playedTracks, currentTrack, playTrack]);
|
||||||
|
|
||||||
const addAlbumToQueue = useCallback(async (albumId: string) => {
|
const addAlbumToQueue = useCallback(async (albumId: string) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { album, songs } = await api.getAlbum(albumId);
|
const { album, songs } = await api.getAlbum(albumId);
|
||||||
const tracks = songs.map(songToTrack);
|
const tracks = songs.map(songToTrack);
|
||||||
setQueue((prevQueue) => [...prevQueue, ...tracks]);
|
|
||||||
|
setQueue((prevQueue) => {
|
||||||
|
if (shuffle && prevQueue.length > 0) {
|
||||||
|
// If shuffle is enabled, shuffle the new tracks and insert them randomly
|
||||||
|
const shuffledTracks = [...tracks];
|
||||||
|
// Fisher-Yates shuffle algorithm
|
||||||
|
for (let i = shuffledTracks.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffledTracks[i], shuffledTracks[j]] = [shuffledTracks[j], shuffledTracks[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert each track at a random position
|
||||||
|
const newQueue = [...prevQueue];
|
||||||
|
shuffledTracks.forEach(track => {
|
||||||
|
const randomIndex = Math.floor(Math.random() * (newQueue.length + 1));
|
||||||
|
newQueue.splice(randomIndex, 0, track);
|
||||||
|
});
|
||||||
|
return newQueue;
|
||||||
|
} else {
|
||||||
|
// Normal behavior: add to the end
|
||||||
|
return [...prevQueue, ...tracks];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Album Added",
|
title: "Album Added",
|
||||||
@@ -168,20 +212,44 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [api, songToTrack, toast]);
|
}, [api, songToTrack, toast, shuffle]);
|
||||||
|
|
||||||
const addArtistToQueue = useCallback(async (artistId: string) => {
|
const addArtistToQueue = useCallback(async (artistId: string) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { artist, albums } = await api.getArtist(artistId);
|
const { artist, albums } = await api.getArtist(artistId);
|
||||||
|
let allTracks: Track[] = [];
|
||||||
|
|
||||||
// Add all albums from this artist to queue
|
// Collect all tracks from all albums
|
||||||
for (const album of albums) {
|
for (const album of albums) {
|
||||||
const { songs } = await api.getAlbum(album.id);
|
const { songs } = await api.getAlbum(album.id);
|
||||||
const tracks = songs.map(songToTrack);
|
const tracks = songs.map(songToTrack);
|
||||||
setQueue((prevQueue) => [...prevQueue, ...tracks]);
|
allTracks = allTracks.concat(tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setQueue((prevQueue) => {
|
||||||
|
if (shuffle && prevQueue.length > 0) {
|
||||||
|
// If shuffle is enabled, shuffle the new tracks and insert them randomly
|
||||||
|
const shuffledTracks = [...allTracks];
|
||||||
|
// Fisher-Yates shuffle algorithm
|
||||||
|
for (let i = shuffledTracks.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffledTracks[i], shuffledTracks[j]] = [shuffledTracks[j], shuffledTracks[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert each track at a random position
|
||||||
|
const newQueue = [...prevQueue];
|
||||||
|
shuffledTracks.forEach(track => {
|
||||||
|
const randomIndex = Math.floor(Math.random() * (newQueue.length + 1));
|
||||||
|
newQueue.splice(randomIndex, 0, track);
|
||||||
|
});
|
||||||
|
return newQueue;
|
||||||
|
} else {
|
||||||
|
// Normal behavior: add to the end
|
||||||
|
return [...prevQueue, ...allTracks];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Artist Added",
|
title: "Artist Added",
|
||||||
description: `Added all albums by "${artist.name}" to queue`,
|
description: `Added all albums by "${artist.name}" to queue`,
|
||||||
@@ -196,24 +264,36 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [api, songToTrack, toast]);
|
}, [api, songToTrack, toast, shuffle]);
|
||||||
const playAlbum = useCallback(async (albumId: string) => {
|
const playAlbum = useCallback(async (albumId: string) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { album, songs } = await api.getAlbum(albumId);
|
const { album, songs } = await api.getAlbum(albumId);
|
||||||
const tracks = songs.map(songToTrack);
|
const tracks = songs.map(songToTrack);
|
||||||
|
|
||||||
// Clear the queue and set the new tracks
|
|
||||||
setQueue(tracks.slice(1)); // All tracks except the first one
|
|
||||||
|
|
||||||
// Play the first track immediately
|
|
||||||
if (tracks.length > 0) {
|
if (tracks.length > 0) {
|
||||||
playTrack(tracks[0]);
|
if (shuffle) {
|
||||||
|
// If shuffle is enabled, shuffle the tracks
|
||||||
|
const shuffledTracks = [...tracks];
|
||||||
|
// Fisher-Yates shuffle algorithm
|
||||||
|
for (let i = shuffledTracks.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffledTracks[i], shuffledTracks[j]] = [shuffledTracks[j], shuffledTracks[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play the first shuffled track and set the rest as queue
|
||||||
|
playTrack(shuffledTracks[0]);
|
||||||
|
setQueue(shuffledTracks.slice(1));
|
||||||
|
} else {
|
||||||
|
// Normal order: play first track and set the rest as queue
|
||||||
|
playTrack(tracks[0]);
|
||||||
|
setQueue(tracks.slice(1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Playing Album",
|
title: "Playing Album",
|
||||||
description: `Now playing "${album.name}"`,
|
description: `Now playing "${album.name}"${shuffle ? ' (shuffled)' : ''}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to play album:', error);
|
console.error('Failed to play album:', error);
|
||||||
@@ -225,7 +305,7 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [api, playTrack, songToTrack, toast]);
|
}, [api, playTrack, songToTrack, toast, shuffle]);
|
||||||
|
|
||||||
const playAlbumFromTrack = useCallback(async (albumId: string, startingSongId: string) => {
|
const playAlbumFromTrack = useCallback(async (albumId: string, startingSongId: string) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -240,15 +320,28 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
throw new Error('Starting song not found in album');
|
throw new Error('Starting song not found in album');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the queue and set the remaining tracks after the starting track
|
if (shuffle) {
|
||||||
setQueue(tracks.slice(startingIndex + 1));
|
// If shuffle is enabled, create a shuffled queue but start with the selected track
|
||||||
|
const remainingTracks = [...tracks];
|
||||||
|
remainingTracks.splice(startingIndex, 1); // Remove the starting track
|
||||||
|
|
||||||
// Play the starting track immediately
|
// Shuffle the remaining tracks
|
||||||
playTrack(tracks[startingIndex]);
|
for (let i = remainingTracks.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[remainingTracks[i], remainingTracks[j]] = [remainingTracks[j], remainingTracks[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
setQueue(remainingTracks);
|
||||||
|
playTrack(tracks[startingIndex]);
|
||||||
|
} else {
|
||||||
|
// Normal order: set the remaining tracks after the starting track as queue
|
||||||
|
setQueue(tracks.slice(startingIndex + 1));
|
||||||
|
playTrack(tracks[startingIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Playing Album",
|
title: "Playing Album",
|
||||||
description: `Playing "${album.name}" from "${tracks[startingIndex].name}"`,
|
description: `Playing "${album.name}" from "${tracks[startingIndex].name}"${shuffle ? ' (shuffled)' : ''}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to play album from track:', error);
|
console.error('Failed to play album from track:', error);
|
||||||
@@ -260,7 +353,7 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [api, playTrack, songToTrack, toast]);
|
}, [api, playTrack, songToTrack, toast, shuffle]);
|
||||||
|
|
||||||
const skipToTrackInQueue = useCallback((index: number) => {
|
const skipToTrackInQueue = useCallback((index: number) => {
|
||||||
if (index >= 0 && index < queue.length) {
|
if (index >= 0 && index < queue.length) {
|
||||||
@@ -272,6 +365,111 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
}
|
}
|
||||||
}, [queue, playTrack]);
|
}, [queue, playTrack]);
|
||||||
|
|
||||||
|
const toggleShuffle = useCallback(() => {
|
||||||
|
setShuffle(prev => {
|
||||||
|
const newShuffleState = !prev;
|
||||||
|
|
||||||
|
// If turning shuffle ON, shuffle the current queue
|
||||||
|
if (newShuffleState && queue.length > 0) {
|
||||||
|
setQueue(prevQueue => {
|
||||||
|
const shuffledQueue = [...prevQueue];
|
||||||
|
// Fisher-Yates shuffle algorithm
|
||||||
|
for (let i = shuffledQueue.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffledQueue[i], shuffledQueue[j]] = [shuffledQueue[j], shuffledQueue[i]];
|
||||||
|
}
|
||||||
|
return shuffledQueue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newShuffleState;
|
||||||
|
});
|
||||||
|
}, [queue.length]);
|
||||||
|
|
||||||
|
const shuffleAllAlbums = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const albums = await api.getAlbums('alphabeticalByName', 500, 0);
|
||||||
|
let allTracks: Track[] = [];
|
||||||
|
|
||||||
|
// Concatenate all tracks from each album into a single array
|
||||||
|
for (const album of albums) {
|
||||||
|
const { songs } = await api.getAlbum(album.id);
|
||||||
|
const tracks = songs.map(songToTrack);
|
||||||
|
allTracks = allTracks.concat(tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle the combined tracks array
|
||||||
|
allTracks.sort(() => Math.random() - 0.5);
|
||||||
|
|
||||||
|
// Set the shuffled tracks as the new queue
|
||||||
|
setQueue(allTracks);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Shuffle All Albums",
|
||||||
|
description: `Shuffled ${allTracks.length} tracks from all albums`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to shuffle all albums:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to shuffle all albums",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [api, songToTrack, toast]);
|
||||||
|
|
||||||
|
const playArtist = useCallback(async (artistId: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const { artist, albums } = await api.getArtist(artistId);
|
||||||
|
let allTracks: Track[] = [];
|
||||||
|
|
||||||
|
// Collect all tracks from all albums
|
||||||
|
for (const album of albums) {
|
||||||
|
const { songs } = await api.getAlbum(album.id);
|
||||||
|
const tracks = songs.map(songToTrack);
|
||||||
|
allTracks = allTracks.concat(tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allTracks.length > 0) {
|
||||||
|
if (shuffle) {
|
||||||
|
// If shuffle is enabled, shuffle all tracks
|
||||||
|
const shuffledTracks = [...allTracks];
|
||||||
|
// Fisher-Yates shuffle algorithm
|
||||||
|
for (let i = shuffledTracks.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffledTracks[i], shuffledTracks[j]] = [shuffledTracks[j], shuffledTracks[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play the first shuffled track and set the rest as queue
|
||||||
|
playTrack(shuffledTracks[0]);
|
||||||
|
setQueue(shuffledTracks.slice(1));
|
||||||
|
} else {
|
||||||
|
// Normal order: play first track and set the rest as queue
|
||||||
|
playTrack(allTracks[0]);
|
||||||
|
setQueue(allTracks.slice(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Playing Artist",
|
||||||
|
description: `Now playing all albums by "${artist.name}"${shuffle ? ' (shuffled)' : ''}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to play artist:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to play artist albums",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [api, songToTrack, toast, shuffle, playTrack]);
|
||||||
|
|
||||||
const contextValue = useMemo(() => ({
|
const contextValue = useMemo(() => ({
|
||||||
currentTrack,
|
currentTrack,
|
||||||
playTrack,
|
playTrack,
|
||||||
@@ -286,7 +484,11 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
isLoading,
|
isLoading,
|
||||||
playAlbum,
|
playAlbum,
|
||||||
playAlbumFromTrack,
|
playAlbumFromTrack,
|
||||||
skipToTrackInQueue
|
skipToTrackInQueue,
|
||||||
|
shuffle,
|
||||||
|
toggleShuffle,
|
||||||
|
shuffleAllAlbums,
|
||||||
|
playArtist
|
||||||
}), [
|
}), [
|
||||||
currentTrack,
|
currentTrack,
|
||||||
queue,
|
queue,
|
||||||
@@ -301,7 +503,11 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
playPreviousTrack,
|
playPreviousTrack,
|
||||||
playAlbum,
|
playAlbum,
|
||||||
playAlbumFromTrack,
|
playAlbumFromTrack,
|
||||||
skipToTrackInQueue
|
skipToTrackInQueue,
|
||||||
|
shuffle,
|
||||||
|
toggleShuffle,
|
||||||
|
shuffleAllAlbums,
|
||||||
|
playArtist
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ interface FullScreenPlayerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onClose, onOpenQueue }) => {
|
export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onClose, onOpenQueue }) => {
|
||||||
const { currentTrack, playPreviousTrack, playNextTrack } = useAudioPlayer();
|
const { currentTrack, playPreviousTrack, playNextTrack, shuffle, toggleShuffle } = useAudioPlayer();
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [volume, setVolume] = useState(1);
|
const [volume, setVolume] = useState(1);
|
||||||
@@ -139,53 +139,35 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
|
|
||||||
// Sync with main audio player (improved responsiveness)
|
// Sync with main audio player (improved responsiveness)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let lastUpdate = 0;
|
|
||||||
const throttleMs = 100; // Update at most every 100ms for better responsiveness
|
|
||||||
|
|
||||||
const syncWithMainPlayer = () => {
|
const syncWithMainPlayer = () => {
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastUpdate < throttleMs) return;
|
|
||||||
lastUpdate = now;
|
|
||||||
|
|
||||||
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
|
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
|
||||||
if (mainAudio && currentTrack) {
|
if (mainAudio && currentTrack) {
|
||||||
const newCurrentTime = mainAudio.currentTime;
|
const newCurrentTime = mainAudio.currentTime;
|
||||||
const newDuration = mainAudio.duration || 0;
|
const newDuration = mainAudio.duration || 0;
|
||||||
const newIsPlaying = !mainAudio.paused;
|
const newIsPlaying = !mainAudio.paused;
|
||||||
|
|
||||||
// Always update playing state for better responsiveness
|
// Always update playing state immediately
|
||||||
if (newIsPlaying !== isPlaying) {
|
setIsPlaying(newIsPlaying);
|
||||||
setIsPlaying(newIsPlaying);
|
setCurrentTime(newCurrentTime);
|
||||||
}
|
setDuration(newDuration);
|
||||||
|
setVolume(mainAudio.volume);
|
||||||
|
|
||||||
// Only update state if values have changed significantly
|
|
||||||
if (Math.abs(newCurrentTime - currentTime) > 0.3) {
|
|
||||||
setCurrentTime(newCurrentTime);
|
|
||||||
}
|
|
||||||
if (Math.abs(newDuration - duration) > 0.1) {
|
|
||||||
setDuration(newDuration);
|
|
||||||
}
|
|
||||||
if (newDuration > 0) {
|
if (newDuration > 0) {
|
||||||
const newProgress = (newCurrentTime / newDuration) * 100;
|
const newProgress = (newCurrentTime / newDuration) * 100;
|
||||||
if (Math.abs(newProgress - progress) > 0.1) {
|
setProgress(newProgress);
|
||||||
setProgress(newProgress);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Math.abs(mainAudio.volume - volume) > 0.01) {
|
|
||||||
setVolume(mainAudio.volume);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen && currentTrack) {
|
||||||
// Initial sync
|
// Initial sync
|
||||||
syncWithMainPlayer();
|
syncWithMainPlayer();
|
||||||
|
|
||||||
// Set up interval to keep syncing - more frequent for better responsiveness
|
// Set up interval to keep syncing
|
||||||
const interval = setInterval(syncWithMainPlayer, 50);
|
const interval = setInterval(syncWithMainPlayer, 100);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}
|
}
|
||||||
}, [isOpen, currentTrack]); // Removed other dependencies to prevent loop
|
}, [isOpen, currentTrack?.id]); // React to track changes
|
||||||
|
|
||||||
// Extract dominant color from cover art
|
// Extract dominant color from cover art
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -260,6 +242,20 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
setVolume(newVolume);
|
setVolume(newVolume);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLyricClick = (time: number) => {
|
||||||
|
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
|
||||||
|
if (!mainAudio) return;
|
||||||
|
|
||||||
|
mainAudio.currentTime = time;
|
||||||
|
setCurrentTime(time);
|
||||||
|
|
||||||
|
// Update progress bar as well
|
||||||
|
if (duration > 0) {
|
||||||
|
const newProgress = (time / duration) * 100;
|
||||||
|
setProgress(newProgress);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
if (!seconds || isNaN(seconds)) return '0:00';
|
if (!seconds || isNaN(seconds)) return '0:00';
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
@@ -292,7 +288,7 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
<div className="relative h-full w-full flex flex-col">
|
<div className="relative h-full w-full flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 lg:p-6 flex-shrink-0">
|
<div className="flex items-center justify-between p-4 lg:p-6 flex-shrink-0">
|
||||||
<h2 className="text-lg lg:text-xl font-semibold text-white">Now Playing</h2>
|
<h2 className="text-lg lg:text-xl font-semibold text-white"></h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{onOpenQueue && (
|
{onOpenQueue && (
|
||||||
<button
|
<button
|
||||||
@@ -315,29 +311,33 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 flex flex-col lg:flex-row gap-4 lg:gap-8 p-4 lg:p-6 pt-0 overflow-hidden min-h-0">
|
<div className="flex-1 flex flex-col lg:flex-row gap-4 lg:gap-8 p-4 lg:p-6 pt-0 overflow-hidden min-h-0">
|
||||||
{/* Left Side - Album Art and Controls */}
|
{/* Left Side - Album Art and Controls */}
|
||||||
<div className="flex-1 flex flex-col items-center justify-center max-w-2xl mx-auto lg:mx-0 min-h-0">
|
<div className={`flex flex-col items-center min-h-0 flex-1 min-w-0 ${
|
||||||
|
showLyrics && lyrics.length > 0
|
||||||
|
? 'justify-center lg:justify-start'
|
||||||
|
: 'justify-center'
|
||||||
|
}`}>
|
||||||
{/* Album Art */}
|
{/* Album Art */}
|
||||||
<div className="relative mb-4 lg:mb-8 flex-shrink-0">
|
<div className="relative mb-4 lg:mb-6 flex-shrink-0">
|
||||||
<Image
|
<Image
|
||||||
src={currentTrack.coverArt || '/default-album.png'}
|
src={currentTrack.coverArt || '/default-album.png'}
|
||||||
alt={currentTrack.album}
|
alt={currentTrack.album}
|
||||||
width={320}
|
width={320}
|
||||||
height={320}
|
height={320}
|
||||||
className="w-64 h-64 lg:w-80 lg:h-80 rounded-lg shadow-2xl object-cover"
|
className="w-56 h-56 sm:w-64 sm:h-64 lg:w-80 lg:h-80 rounded-lg shadow-2xl object-cover"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Track Info */}
|
{/* Track Info */}
|
||||||
<div className="text-center mb-4 lg:mb-8 px-4 flex-shrink-0">
|
<div className="text-center mb-4 lg:mb-6 px-4 flex-shrink-0 max-w-full">
|
||||||
<h1 className="text-xl lg:text-3xl font-bold text-foreground mb-2 line-clamp-2">
|
<h1 className="text-lg sm:text-xl lg:text-3xl font-bold text-foreground mb-2 line-clamp-2 leading-tight">
|
||||||
{currentTrack.name}
|
{currentTrack.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg lg:text-xl text-foreground/80 mb-1 line-clamp-1">{currentTrack.artist}</p>
|
<p className="text-base sm:text-lg lg:text-xl text-foreground/80 mb-1 line-clamp-1">{currentTrack.artist}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
<div className="w-full max-w-md mb-4 lg:mb-6 px-4 flex-shrink-0">
|
<div className="w-full max-w-sm lg:max-w-md mb-4 lg:mb-6 px-4 flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
className="h-2 bg-white/20 rounded-full cursor-pointer relative overflow-hidden"
|
className="h-2 bg-white/20 rounded-full cursor-pointer relative overflow-hidden"
|
||||||
onClick={handleSeek}
|
onClick={handleSeek}
|
||||||
@@ -354,57 +354,69 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="flex items-center gap-4 lg:gap-6 mb-4 lg:mb-6 flex-shrink-0">
|
<div className="flex items-center gap-3 sm:gap-4 lg:gap-6 mb-4 lg:mb-6 flex-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-4 h-4 sm:w-5 sm:h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={playPreviousTrack}
|
onClick={playPreviousTrack}
|
||||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||||
<FaBackward className="w-5 h-5" />
|
<FaBackward className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={togglePlayPause}
|
onClick={togglePlayPause}
|
||||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
className="p-3 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<FaPause className="w-10 h-10" />
|
<FaPause className="w-8 h-8 sm:w-10 sm:h-10" />
|
||||||
) : (
|
) : (
|
||||||
<FaPlay className="w-10 h-10" />
|
<FaPlay className="w-8 h-8 sm:w-10 sm:h-10" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={playNextTrack}
|
onClick={playNextTrack}
|
||||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||||
<FaForward className="w-5 h-5" />
|
<FaForward className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Volume and Queue */}
|
|
||||||
<div className="flex items-center gap-3 flex-shrink-0">
|
|
||||||
<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>
|
</button>
|
||||||
|
|
||||||
{lyrics.length > 0 && (
|
{lyrics.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowLyrics(!showLyrics)}
|
onClick={() => setShowLyrics(!showLyrics)}
|
||||||
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${
|
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${
|
||||||
showLyrics ? 'text-foreground' : 'text-gray-500'
|
showLyrics ? 'text-primary bg-primary/20' : 'text-gray-500'
|
||||||
}`}
|
}`}
|
||||||
title={showLyrics ? 'Hide Lyrics' : 'Show Lyrics'}
|
title={showLyrics ? 'Hide Lyrics' : 'Show Lyrics'}
|
||||||
>
|
>
|
||||||
<FaQuoteLeft className="w-5 h-5" />
|
<FaQuoteLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volume and Lyrics Toggle */}
|
||||||
|
<div className="flex items-center gap-3 flex-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" />
|
||||||
|
) : (
|
||||||
|
<FaVolumeHigh className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
|
||||||
{showVolumeSlider && (
|
{showVolumeSlider && (
|
||||||
<div
|
<div
|
||||||
className="w-20 lg:w-24"
|
className="w-16 sm:w-20 lg:w-24"
|
||||||
onMouseLeave={() => setShowVolumeSlider(false)}
|
onMouseLeave={() => setShowVolumeSlider(false)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -422,17 +434,18 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
|
|
||||||
{/* Right Side - Lyrics */}
|
{/* Right Side - Lyrics */}
|
||||||
{showLyrics && lyrics.length > 0 && (
|
{showLyrics && lyrics.length > 0 && (
|
||||||
<div className="flex-1 lg:max-w-md min-h-0" ref={lyricsRef}>
|
<div className="flex-1 min-w-0 min-h-0 flex flex-col" ref={lyricsRef}>
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<ScrollArea className="flex-1 min-h-0 w-90">
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
<div className="space-y-4 pr-4 px-2">
|
<div className="space-y-3 sm:space-y-4 pr-4 px-2 py-4">
|
||||||
{lyrics.map((line, index) => (
|
{lyrics.map((line, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
data-lyric-index={index}
|
data-lyric-index={index}
|
||||||
className={`text-sm lg:text-base leading-relaxed transition-all duration-300 break-words ${
|
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 hover:scale-102 ${
|
||||||
index === currentLyricIndex
|
index === currentLyricIndex
|
||||||
? 'text-foreground font-semibold text-lg lg:text-xl scale-105'
|
? 'text-foreground font-semibold text-base sm:text-lg lg:text-xl scale-105'
|
||||||
: index < currentLyricIndex
|
: index < currentLyricIndex
|
||||||
? 'text-foreground/60'
|
? 'text-foreground/60'
|
||||||
: 'text-foreground/40'
|
: 'text-foreground/40'
|
||||||
@@ -441,9 +454,10 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
wordWrap: 'break-word',
|
wordWrap: 'break-word',
|
||||||
overflowWrap: 'break-word',
|
overflowWrap: 'break-word',
|
||||||
hyphens: 'auto',
|
hyphens: 'auto',
|
||||||
paddingBottom: '8px',
|
paddingBottom: '6px',
|
||||||
paddingLeft: '9px'
|
paddingLeft: '8px'
|
||||||
}}
|
}}
|
||||||
|
title={`Click to jump to ${formatTime(line.time)}`}
|
||||||
>
|
>
|
||||||
{line.text || '♪'}
|
{line.text || '♪'}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||||
import { getNavidromeAPI, Album, Artist, Song, Playlist } from '@/lib/navidrome';
|
import { getNavidromeAPI, Album, Artist, Song, Playlist, AlbumInfo, ArtistInfo } from '@/lib/navidrome';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
interface NavidromeContextType {
|
interface NavidromeContextType {
|
||||||
@@ -23,8 +23,11 @@ interface NavidromeContextType {
|
|||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
searchMusic: (query: string) => Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }>;
|
searchMusic: (query: string) => Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }>;
|
||||||
|
search2: (query: string) => Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }>;
|
||||||
getAlbum: (albumId: string) => Promise<{ album: Album; songs: Song[] }>;
|
getAlbum: (albumId: string) => Promise<{ album: Album; songs: Song[] }>;
|
||||||
getArtist: (artistId: string) => Promise<{ artist: Artist; albums: Album[] }>;
|
getArtist: (artistId: string) => Promise<{ artist: Artist; albums: Album[] }>;
|
||||||
|
getArtistInfo2: (artistId: string) => Promise<ArtistInfo>;
|
||||||
|
getAlbumInfo2: (albumId: string) => Promise<AlbumInfo>;
|
||||||
getPlaylist: (playlistId: string) => Promise<{ playlist: Playlist; songs: Song[] }>;
|
getPlaylist: (playlistId: string) => Promise<{ playlist: Playlist; songs: Song[] }>;
|
||||||
getAllSongs: () => Promise<Song[]>;
|
getAllSongs: () => Promise<Song[]>;
|
||||||
refreshData: () => Promise<void>;
|
refreshData: () => Promise<void>;
|
||||||
@@ -123,6 +126,17 @@ export const NavidromeProvider: React.FC<NavidromeProviderProps> = ({ children }
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const search2 = async (query: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
return await api.search2(query);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Search2 failed:', err);
|
||||||
|
setError('Search failed');
|
||||||
|
return { artists: [], albums: [], songs: [] };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getAlbum = async (albumId: string) => {
|
const getAlbum = async (albumId: string) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -145,6 +159,28 @@ export const NavidromeProvider: React.FC<NavidromeProviderProps> = ({ children }
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getArtistInfo2 = async (artistId: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
return await api.getArtistInfo2(artistId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get artist info:', err);
|
||||||
|
setError('Failed to get artist info');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlbumInfo2 = async (albumId: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
return await api.getAlbumInfo2(albumId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get album info:', err);
|
||||||
|
setError('Failed to get album info');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getPlaylist = async (playlistId: string) => {
|
const getPlaylist = async (playlistId: string) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -276,8 +312,11 @@ export const NavidromeProvider: React.FC<NavidromeProviderProps> = ({ children }
|
|||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
searchMusic,
|
searchMusic,
|
||||||
|
search2,
|
||||||
getAlbum,
|
getAlbum,
|
||||||
getArtist,
|
getArtist,
|
||||||
|
getArtistInfo2,
|
||||||
|
getAlbumInfo2,
|
||||||
getPlaylist,
|
getPlaylist,
|
||||||
getAllSongs,
|
getAllSongs,
|
||||||
refreshData,
|
refreshData,
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function AlbumArtwork({
|
|||||||
height={height}
|
height={height}
|
||||||
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-auto w-auto object-cover transition-all hover:scale-105",
|
"w-full h-full object-cover transition-all hover:scale-105",
|
||||||
aspectRatio === "portrait" ? "aspect-[3/4]" : "aspect-square"
|
aspectRatio === "portrait" ? "aspect-[3/4]" : "aspect-square"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
export function Sidebar({ className, playlists }: SidebarProps) {
|
export function Sidebar({ className, playlists }: SidebarProps) {
|
||||||
const isRoot = usePathname() === "/";
|
const isRoot = usePathname() === "/";
|
||||||
const isBrowse = usePathname() === "/browse";
|
const isBrowse = usePathname() === "/browse";
|
||||||
|
const isSearch = usePathname() === "/search";
|
||||||
const isAlbums = usePathname() === "/library/albums";
|
const isAlbums = usePathname() === "/library/albums";
|
||||||
const isArtists = usePathname() === "/library/artists";
|
const isArtists = usePathname() === "/library/artists";
|
||||||
const isQueue = usePathname() === "/queue";
|
const isQueue = usePathname() === "/queue";
|
||||||
@@ -68,6 +69,24 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
|||||||
Browse
|
Browse
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/search">
|
||||||
|
<Button variant={isSearch ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
<Link href="/queue">
|
<Link href="/queue">
|
||||||
<Button variant={isQueue ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
<Button variant={isQueue ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
10
app/page.tsx
10
app/page.tsx
@@ -38,7 +38,7 @@ export default function MusicPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full px-4 py-6 lg:px-8">
|
<div className="h-full px-4 py-6 lg:px-8 pb-24">
|
||||||
<>
|
<>
|
||||||
<Tabs defaultValue="music" className="h-full space-y-6">
|
<Tabs defaultValue="music" className="h-full space-y-6">
|
||||||
<TabsContent value="music" className="border-none p-0 outline-none">
|
<TabsContent value="music" className="border-none p-0 outline-none">
|
||||||
@@ -59,14 +59,14 @@ export default function MusicPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
// Loading skeletons
|
// Loading skeletons
|
||||||
Array.from({ length: 6 }).map((_, i) => (
|
Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div key={i} className="w-[300px] h-[300px] bg-muted animate-pulse rounded-md" />
|
<div key={i} className="w-[300px] h-[300px] bg-muted animate-pulse rounded-md flex-shrink-0" />
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
recentAlbums.map((album) => (
|
recentAlbums.map((album) => (
|
||||||
<AlbumArtwork
|
<AlbumArtwork
|
||||||
key={album.id}
|
key={album.id}
|
||||||
album={album}
|
album={album}
|
||||||
className="w-[300px]"
|
className="w-[300px] flex-shrink-0"
|
||||||
aspectRatio="square"
|
aspectRatio="square"
|
||||||
width={300}
|
width={300}
|
||||||
height={300}
|
height={300}
|
||||||
@@ -92,14 +92,14 @@ export default function MusicPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
// Loading skeletons
|
// Loading skeletons
|
||||||
Array.from({ length: 10 }).map((_, i) => (
|
Array.from({ length: 10 }).map((_, i) => (
|
||||||
<div key={i} className="w-[150px] h-[150px] bg-muted animate-pulse rounded-md" />
|
<div key={i} className="w-[150px] h-[150px] bg-muted animate-pulse rounded-md flex-shrink-0" />
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
newestAlbums.map((album) => (
|
newestAlbums.map((album) => (
|
||||||
<AlbumArtwork
|
<AlbumArtwork
|
||||||
key={album.id}
|
key={album.id}
|
||||||
album={album}
|
album={album}
|
||||||
className="w-[150px]"
|
className="w-[150px] flex-shrink-0"
|
||||||
aspectRatio="square"
|
aspectRatio="square"
|
||||||
width={150}
|
width={150}
|
||||||
height={150}
|
height={150}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
@@ -18,7 +19,7 @@ const QueuePage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full px-4 py-6 lg:px-8">
|
<div className="h-full px-4 py-6 lg:px-8 pb-24">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -69,11 +70,15 @@ const QueuePage: React.FC = () => {
|
|||||||
<div className="flex items-center text-sm text-muted-foreground space-x-4">
|
<div className="flex items-center text-sm text-muted-foreground space-x-4">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<User className="w-3 h-3" />
|
<User className="w-3 h-3" />
|
||||||
<span className="truncate">{currentTrack.artist}</span>
|
<Link href={`/artist/${currentTrack.artistId}`} className="truncate hover:text-primary hover:underline">
|
||||||
|
{currentTrack.artist}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Disc className="w-3 h-3" />
|
<Disc className="w-3 h-3" />
|
||||||
<span className="truncate">{currentTrack.album}</span>
|
<Link href={`/album/${currentTrack.albumId}`} className="truncate hover:text-primary hover:underline">
|
||||||
|
{currentTrack.album}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,11 +148,23 @@ const QueuePage: React.FC = () => {
|
|||||||
<div className="flex items-center text-sm text-muted-foreground space-x-4">
|
<div className="flex items-center text-sm text-muted-foreground space-x-4">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<User className="w-3 h-3" />
|
<User className="w-3 h-3" />
|
||||||
<span className="truncate">{track.artist}</span>
|
<Link
|
||||||
|
href={`/artist/${track.artistId}`}
|
||||||
|
className="truncate hover:text-primary hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{track.artist}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Disc className="w-3 h-3" />
|
<Disc className="w-3 h-3" />
|
||||||
<span className="truncate">{track.album}</span>
|
<Link
|
||||||
|
href={`/album/${track.albumId}`}
|
||||||
|
className="truncate hover:text-primary hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{track.album}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
242
app/search/page.tsx
Normal file
242
app/search/page.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { AlbumArtwork } from '@/app/components/album-artwork';
|
||||||
|
import { ArtistIcon } from '@/app/components/artist-icon';
|
||||||
|
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||||
|
import { getNavidromeAPI, Artist, Album, Song } from '@/lib/navidrome';
|
||||||
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||||
|
import { Search, Play, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
const { search2 } = useNavidrome();
|
||||||
|
const { addToQueue, playTrack } = useAudioPlayer();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<{
|
||||||
|
artists: Artist[];
|
||||||
|
albums: Album[];
|
||||||
|
songs: Song[];
|
||||||
|
}>({ artists: [], albums: [], songs: [] });
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const api = getNavidromeAPI();
|
||||||
|
|
||||||
|
const handleSearch = async (query: string) => {
|
||||||
|
if (query.trim() === '') {
|
||||||
|
setSearchResults({ artists: [], albums: [], songs: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSearching(true);
|
||||||
|
const results = await search2(query);
|
||||||
|
setSearchResults(results);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
setSearchResults({ artists: [], albums: [], songs: [] });
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
handleSearch(searchQuery);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const handlePlaySong = (song: Song) => {
|
||||||
|
const track = {
|
||||||
|
id: song.id,
|
||||||
|
name: song.title,
|
||||||
|
url: api.getStreamUrl(song.id),
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
duration: song.duration,
|
||||||
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||||
|
albumId: song.albumId,
|
||||||
|
artistId: song.artistId
|
||||||
|
};
|
||||||
|
|
||||||
|
playTrack(track);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToQueue = (song: Song) => {
|
||||||
|
const track = {
|
||||||
|
id: song.id,
|
||||||
|
name: song.title,
|
||||||
|
url: api.getStreamUrl(song.id),
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
duration: song.duration,
|
||||||
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||||
|
albumId: song.albumId,
|
||||||
|
artistId: song.artistId
|
||||||
|
};
|
||||||
|
|
||||||
|
addToQueue(track);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (duration: number): string => {
|
||||||
|
const minutes = Math.floor(duration / 60);
|
||||||
|
const seconds = duration % 60;
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full px-4 py-6 lg:px-8">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Search</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Search for artists, albums, and songs in your music library
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="relative max-w-lg">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||||
|
<Input
|
||||||
|
placeholder="What do you want to listen to?"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 text-lg h-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isSearching && <div className="text-sm text-muted-foreground">Searching...</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Results */}
|
||||||
|
{searchQuery && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{searchResults.artists.length === 0 && searchResults.albums.length === 0 && searchResults.songs.length === 0 && !isSearching && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground text-lg">No results found for "{searchQuery}"</p>
|
||||||
|
<p className="text-muted-foreground text-sm mt-2">Try different keywords or check your spelling</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Artists */}
|
||||||
|
{searchResults.artists.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Artists</h2>
|
||||||
|
<ScrollArea className="w-full">
|
||||||
|
<div className="flex space-x-4 pb-4">
|
||||||
|
{searchResults.artists.map((artist) => (
|
||||||
|
<ArtistIcon key={artist.id} artist={artist} className="flex-shrink-0" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Albums */}
|
||||||
|
{searchResults.albums.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Albums</h2>
|
||||||
|
<ScrollArea className="w-full">
|
||||||
|
<div className="flex space-x-4 pb-4">
|
||||||
|
{searchResults.albums.map((album) => (
|
||||||
|
<AlbumArtwork
|
||||||
|
key={album.id}
|
||||||
|
album={album}
|
||||||
|
className="flex-shrink-0 w-48"
|
||||||
|
aspectRatio="square"
|
||||||
|
width={192}
|
||||||
|
height={192}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Songs */}
|
||||||
|
{searchResults.songs.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Songs</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{searchResults.songs.slice(0, 10).map((song, index) => (
|
||||||
|
<div key={song.id} className="group flex items-center space-x-3 p-3 hover:bg-accent rounded-lg transition-colors">
|
||||||
|
<div className="w-8 text-center text-sm text-muted-foreground">
|
||||||
|
<span className="group-hover:hidden">{index + 1}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePlaySong(song)}
|
||||||
|
className="hidden group-hover:flex h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Song Cover */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : '/default-user.jpg'}
|
||||||
|
alt={song.album}
|
||||||
|
className="w-12 h-12 rounded-md object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Song Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{song.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">{song.artist} • {song.album}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration */}
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{formatDuration(song.duration)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddToQueue(song)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{searchResults.songs.length > 10 && (
|
||||||
|
<div className="text-center pt-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing first 10 of {searchResults.songs.length} songs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!searchQuery && (
|
||||||
|
<div className="text-center py-24">
|
||||||
|
<Search className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold mb-2">Search your music</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Find your favorite artists, albums, and songs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -89,6 +89,26 @@ export interface RadioStation {
|
|||||||
homePageUrl?: string;
|
homePageUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AlbumInfo {
|
||||||
|
notes?: string;
|
||||||
|
musicBrainzId?: string;
|
||||||
|
lastFmUrl?: string;
|
||||||
|
smallImageUrl?: string;
|
||||||
|
mediumImageUrl?: string;
|
||||||
|
largeImageUrl?: string;
|
||||||
|
biography?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistInfo {
|
||||||
|
biography?: string;
|
||||||
|
musicBrainzId?: string;
|
||||||
|
lastFmUrl?: string;
|
||||||
|
smallImageUrl?: string;
|
||||||
|
mediumImageUrl?: string;
|
||||||
|
largeImageUrl?: string;
|
||||||
|
similarArtist?: Artist[];
|
||||||
|
}
|
||||||
|
|
||||||
class NavidromeAPI {
|
class NavidromeAPI {
|
||||||
private config: NavidromeConfig;
|
private config: NavidromeConfig;
|
||||||
private clientName = 'stillnavidrome';
|
private clientName = 'stillnavidrome';
|
||||||
@@ -106,7 +126,7 @@ class NavidromeAPI {
|
|||||||
return crypto.createHash('md5').update(password + salt).digest('hex');
|
return crypto.createHash('md5').update(password + salt).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async makeRequest(endpoint: string, params: Record<string, string | number> = {}): Promise<Record<string, unknown>> {
|
async makeRequest(endpoint: string, params: Record<string, string | number> = {}): Promise<Record<string, unknown>> {
|
||||||
const salt = this.generateSalt();
|
const salt = this.generateSalt();
|
||||||
const token = this.generateToken(this.config.password, salt);
|
const token = this.generateToken(this.config.password, salt);
|
||||||
|
|
||||||
@@ -175,7 +195,7 @@ class NavidromeAPI {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAlbums(type?: 'newest' | 'recent' | 'frequent' | 'random', size: number = 50, offset: number = 0): Promise<Album[]> {
|
async getAlbums(type?: 'newest' | 'recent' | 'frequent' | 'random' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'starred' | 'highest', size: number = 500, offset: number = 0): Promise<Album[]> {
|
||||||
const response = await this.makeRequest('getAlbumList2', {
|
const response = await this.makeRequest('getAlbumList2', {
|
||||||
type: type || 'newest',
|
type: type || 'newest',
|
||||||
size,
|
size,
|
||||||
@@ -365,6 +385,67 @@ class NavidromeAPI {
|
|||||||
async deleteInternetRadioStation(id: string): Promise<void> {
|
async deleteInternetRadioStation(id: string): Promise<void> {
|
||||||
await this.makeRequest('deleteInternetRadioStation', { id });
|
await this.makeRequest('deleteInternetRadioStation', { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getArtistInfo(artistId: string): Promise<{ artist: Artist; info: ArtistInfo }> {
|
||||||
|
const response = await this.makeRequest('getArtistInfo2', { id: artistId });
|
||||||
|
const artistData = response.artist as Artist;
|
||||||
|
const artistInfo = response.info as ArtistInfo;
|
||||||
|
return {
|
||||||
|
artist: artistData,
|
||||||
|
info: artistInfo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbumInfo(albumId: string): Promise<{ album: Album; info: AlbumInfo }> {
|
||||||
|
const response = await this.makeRequest('getAlbumInfo2', { id: albumId });
|
||||||
|
const albumData = response.album as Album;
|
||||||
|
const albumInfo = response.info as AlbumInfo;
|
||||||
|
return {
|
||||||
|
album: albumData,
|
||||||
|
info: albumInfo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async search2(query: string, artistCount = 20, albumCount = 20, songCount = 20): Promise<{
|
||||||
|
artists: Artist[];
|
||||||
|
albums: Album[];
|
||||||
|
songs: Song[];
|
||||||
|
}> {
|
||||||
|
const response = await this.makeRequest('search2', {
|
||||||
|
query,
|
||||||
|
artistCount,
|
||||||
|
albumCount,
|
||||||
|
songCount
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchData = response.searchResult2 as {
|
||||||
|
artist?: Artist[];
|
||||||
|
album?: Album[];
|
||||||
|
song?: Song[];
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
artists: searchData?.artist || [],
|
||||||
|
albums: searchData?.album || [],
|
||||||
|
songs: searchData?.song || []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getArtistInfo2(artistId: string, count = 20, includeNotPresent = false): Promise<ArtistInfo> {
|
||||||
|
const response = await this.makeRequest('getArtistInfo2', {
|
||||||
|
id: artistId,
|
||||||
|
count,
|
||||||
|
includeNotPresent: includeNotPresent.toString()
|
||||||
|
});
|
||||||
|
return response.artistInfo2 as ArtistInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbumInfo2(albumId: string): Promise<AlbumInfo> {
|
||||||
|
const response = await this.makeRequest('getAlbumInfo2', {
|
||||||
|
id: albumId
|
||||||
|
});
|
||||||
|
return response.albumInfo2 as AlbumInfo;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance management
|
// Singleton instance management
|
||||||
|
|||||||
Reference in New Issue
Block a user