Initial release v1.0.0
This commit is contained in:
121
frontend/features/library/components/AlbumsGrid.tsx
Normal file
121
frontend/features/library/components/AlbumsGrid.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { memo, useCallback } from "react";
|
||||
import { Album } from "../types";
|
||||
import { PlayableCard } from "@/components/ui/PlayableCard";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { GradientSpinner } from "@/components/ui/GradientSpinner";
|
||||
import { Disc3, Trash2 } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface AlbumsGridProps {
|
||||
albums: Album[];
|
||||
onPlay: (albumId: string) => Promise<void>;
|
||||
onDelete: (albumId: string, albumTitle: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface AlbumCardItemProps {
|
||||
album: Album;
|
||||
index: number;
|
||||
onPlay: (albumId: string) => Promise<void>;
|
||||
onDelete: (albumId: string, albumTitle: string) => void;
|
||||
}
|
||||
|
||||
const AlbumCardItem = memo(
|
||||
function AlbumCardItem({
|
||||
album,
|
||||
index,
|
||||
onPlay,
|
||||
onDelete,
|
||||
}: AlbumCardItemProps) {
|
||||
const handlePlay = useCallback(
|
||||
() => onPlay(album.id),
|
||||
[album.id, onPlay]
|
||||
);
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete(album.id, album.title);
|
||||
},
|
||||
[album.id, album.title, onDelete]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<PlayableCard
|
||||
href={`/album/${album.id}`}
|
||||
coverArt={
|
||||
album.coverArt
|
||||
? api.getCoverArtUrl(album.coverArt, 300)
|
||||
: null
|
||||
}
|
||||
title={album.title}
|
||||
subtitle={album.artist?.name}
|
||||
placeholderIcon={
|
||||
<Disc3 className="w-10 h-10 text-gray-600" />
|
||||
}
|
||||
circular={false}
|
||||
onPlay={handlePlay}
|
||||
data-tv-card
|
||||
data-tv-card-index={index}
|
||||
tabIndex={0}
|
||||
/>
|
||||
{/* Delete button - only visible on hover */}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-red-600 transition-all z-10"
|
||||
title="Delete album"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.album.id === nextProps.album.id;
|
||||
}
|
||||
);
|
||||
|
||||
const AlbumsGrid = memo(function AlbumsGrid({
|
||||
albums,
|
||||
onPlay,
|
||||
onDelete,
|
||||
isLoading = false,
|
||||
}: AlbumsGridProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<GradientSpinner size="md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (albums.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Disc3 className="w-12 h-12" />}
|
||||
title="No albums yet"
|
||||
description="Your library is empty. Sync your music to get started."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tv-section="library-albums"
|
||||
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"
|
||||
>
|
||||
{albums.map((album, index) => (
|
||||
<AlbumCardItem
|
||||
key={album.id}
|
||||
album={album}
|
||||
index={index}
|
||||
onPlay={onPlay}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { AlbumsGrid };
|
||||
122
frontend/features/library/components/ArtistsGrid.tsx
Normal file
122
frontend/features/library/components/ArtistsGrid.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React, { memo, useCallback } from "react";
|
||||
import { Music, Trash2 } from "lucide-react";
|
||||
import { Artist } from "../types";
|
||||
import { PlayableCard } from "@/components/ui/PlayableCard";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { GradientSpinner } from "@/components/ui/GradientSpinner";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface ArtistsGridProps {
|
||||
artists: Artist[];
|
||||
onPlay: (artistId: string) => Promise<void>;
|
||||
onDelete: (artistId: string, artistName: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const getArtistImageSrc = (coverArt?: string): string | null => {
|
||||
if (!coverArt) return null;
|
||||
return api.getCoverArtUrl(coverArt, 300);
|
||||
};
|
||||
|
||||
interface ArtistCardItemProps {
|
||||
artist: Artist;
|
||||
index: number;
|
||||
onPlay: (artistId: string) => Promise<void>;
|
||||
onDelete: (artistId: string, artistName: string) => void;
|
||||
}
|
||||
|
||||
const ArtistCardItem = memo(
|
||||
function ArtistCardItem({
|
||||
artist,
|
||||
index,
|
||||
onPlay,
|
||||
onDelete,
|
||||
}: ArtistCardItemProps) {
|
||||
const handlePlay = useCallback(
|
||||
() => onPlay(artist.id),
|
||||
[artist.id, onPlay]
|
||||
);
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete(artist.id, artist.name);
|
||||
},
|
||||
[artist.id, artist.name, onDelete]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<PlayableCard
|
||||
href={`/artist/${artist.mbid || artist.id}`}
|
||||
coverArt={getArtistImageSrc(artist.coverArt)}
|
||||
title={artist.name}
|
||||
subtitle={`${artist.albumCount || 0} albums`}
|
||||
placeholderIcon={
|
||||
<Music className="w-10 h-10 text-gray-600" />
|
||||
}
|
||||
circular={true}
|
||||
onPlay={handlePlay}
|
||||
data-tv-card
|
||||
data-tv-card-index={index}
|
||||
tabIndex={0}
|
||||
/>
|
||||
{/* Delete button - only visible on hover */}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-red-600 transition-all z-10"
|
||||
title="Delete artist"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.artist.id === nextProps.artist.id;
|
||||
}
|
||||
);
|
||||
|
||||
const ArtistsGrid = memo(function ArtistsGrid({
|
||||
artists,
|
||||
onPlay,
|
||||
onDelete,
|
||||
isLoading = false,
|
||||
}: ArtistsGridProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<GradientSpinner size="md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (artists.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Music className="w-12 h-12" />}
|
||||
title="No artists yet"
|
||||
description="Your library is empty. Sync your music to get started."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tv-section="library-artists"
|
||||
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"
|
||||
>
|
||||
{artists.map((artist, index) => (
|
||||
<ArtistCardItem
|
||||
key={artist.id}
|
||||
artist={artist}
|
||||
index={index}
|
||||
onPlay={onPlay}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { ArtistsGrid };
|
||||
24
frontend/features/library/components/LibraryHeader.tsx
Normal file
24
frontend/features/library/components/LibraryHeader.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
export function LibraryHeader() {
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Quick gradient fade - yellow to purple */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-b from-[#ecb200]/15 via-purple-900/10 to-transparent"
|
||||
style={{ height: "35vh" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-[#ecb200]/8 via-transparent to-transparent"
|
||||
style={{ height: "25vh" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Compact header */}
|
||||
<div className="relative px-4 md:px-8 pt-6 pb-2">
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Your Library
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
frontend/features/library/components/LibraryTabs.tsx
Normal file
56
frontend/features/library/components/LibraryTabs.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Tab } from "../types";
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
interface LibraryTabsProps {
|
||||
activeTab: Tab;
|
||||
onTabChange: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
export function LibraryTabs({ activeTab, onTabChange }: LibraryTabsProps) {
|
||||
return (
|
||||
<div data-tv-section="library-tabs" className="flex gap-2 mb-4">
|
||||
<button
|
||||
data-tv-card
|
||||
data-tv-card-index={0}
|
||||
tabIndex={0}
|
||||
onClick={() => onTabChange("artists")}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-medium rounded-full transition-all",
|
||||
activeTab === "artists"
|
||||
? "bg-white text-black"
|
||||
: "bg-white/10 text-white hover:bg-white/15"
|
||||
)}
|
||||
>
|
||||
Artists
|
||||
</button>
|
||||
<button
|
||||
data-tv-card
|
||||
data-tv-card-index={1}
|
||||
tabIndex={0}
|
||||
onClick={() => onTabChange("albums")}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-medium rounded-full transition-all",
|
||||
activeTab === "albums"
|
||||
? "bg-white text-black"
|
||||
: "bg-white/10 text-white hover:bg-white/15"
|
||||
)}
|
||||
>
|
||||
Albums
|
||||
</button>
|
||||
<button
|
||||
data-tv-card
|
||||
data-tv-card-index={2}
|
||||
tabIndex={0}
|
||||
onClick={() => onTabChange("tracks")}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-medium rounded-full transition-all",
|
||||
activeTab === "tracks"
|
||||
? "bg-white text-black"
|
||||
: "bg-white/10 text-white hover:bg-white/15"
|
||||
)}
|
||||
>
|
||||
Songs
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
238
frontend/features/library/components/TracksList.tsx
Normal file
238
frontend/features/library/components/TracksList.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { useState, memo, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import { Track } from "../types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PlaylistSelector } from "@/components/ui/PlaylistSelector";
|
||||
import { GradientSpinner } from "@/components/ui/GradientSpinner";
|
||||
import { AudioLines, ListPlus, Plus, Trash2, Play } from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface TracksListProps {
|
||||
tracks: Track[];
|
||||
onPlay: (tracks: Track[], startIndex?: number) => void;
|
||||
onAddToQueue: (track: Track) => void;
|
||||
onAddToPlaylist: (playlistId: string, trackId: string) => void;
|
||||
onDelete: (trackId: string, trackTitle: string) => void;
|
||||
currentTrackId?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
interface TrackRowProps {
|
||||
track: Track;
|
||||
index: number;
|
||||
isCurrentlyPlaying: boolean;
|
||||
onPlayTrack: () => void;
|
||||
onAddToQueue: (track: Track) => void;
|
||||
onShowAddToPlaylist: (trackId: string) => void;
|
||||
onDelete: (trackId: string, trackTitle: string) => void;
|
||||
}
|
||||
|
||||
const TrackRow = memo(function TrackRow({
|
||||
track,
|
||||
index,
|
||||
isCurrentlyPlaying,
|
||||
onPlayTrack,
|
||||
onAddToQueue,
|
||||
onShowAddToPlaylist,
|
||||
onDelete,
|
||||
}: TrackRowProps) {
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
onClick={onPlayTrack}
|
||||
data-tv-card
|
||||
data-tv-card-index={index}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"grid grid-cols-[auto_1fr_auto] md:grid-cols-[auto_1fr_1fr_auto] items-center gap-3 px-3 py-2 rounded-md hover:bg-white/5 transition-colors group cursor-pointer",
|
||||
isCurrentlyPlaying && "bg-white/5"
|
||||
)}
|
||||
>
|
||||
{/* Track number / Play icon */}
|
||||
<div className="w-8 flex items-center justify-center">
|
||||
<span className={cn(
|
||||
"text-sm group-hover:hidden",
|
||||
isCurrentlyPlaying ? "text-[#ecb200]" : "text-gray-500"
|
||||
)}>
|
||||
{isCurrentlyPlaying ? (
|
||||
<AudioLines className="w-4 h-4 text-[#ecb200]" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</span>
|
||||
<Play className="w-4 h-4 text-white hidden group-hover:block fill-current" />
|
||||
</div>
|
||||
|
||||
{/* Cover + Title/Artist */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="relative w-10 h-10 bg-[#282828] rounded flex items-center justify-center overflow-hidden shrink-0">
|
||||
{track.album?.coverArt ? (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(track.album.coverArt, 80)}
|
||||
alt={track.title}
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<AudioLines className="w-4 h-4 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className={cn(
|
||||
"text-sm font-medium truncate",
|
||||
isCurrentlyPlaying ? "text-[#ecb200]" : "text-white"
|
||||
)}>
|
||||
{track.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{track.album?.artist?.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Album - hidden on mobile */}
|
||||
<div className="hidden md:block min-w-0">
|
||||
<p className="text-sm text-gray-400 truncate">
|
||||
{track.album?.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions + Duration */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddToQueue(track);
|
||||
}}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-gray-400 hover:text-white hover:bg-white/10 opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Add to Queue"
|
||||
>
|
||||
<ListPlus className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShowAddToPlaylist(track.id);
|
||||
}}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-gray-400 hover:text-white hover:bg-white/10 opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Add to Playlist"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(track.id, track.title);
|
||||
}}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-gray-400 hover:text-red-500 hover:bg-white/10 opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Delete Track"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">
|
||||
{formatDuration(track.duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.track.id === nextProps.track.id &&
|
||||
prevProps.isCurrentlyPlaying === nextProps.isCurrentlyPlaying &&
|
||||
prevProps.index === nextProps.index
|
||||
);
|
||||
});
|
||||
|
||||
export function TracksList({
|
||||
tracks,
|
||||
onPlay,
|
||||
onAddToQueue,
|
||||
onAddToPlaylist,
|
||||
onDelete,
|
||||
currentTrackId,
|
||||
isLoading = false,
|
||||
}: TracksListProps) {
|
||||
const [showPlaylistSelector, setShowPlaylistSelector] = useState(false);
|
||||
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null);
|
||||
|
||||
const handleShowAddToPlaylist = useCallback((trackId: string) => {
|
||||
setSelectedTrackId(trackId);
|
||||
setShowPlaylistSelector(true);
|
||||
}, []);
|
||||
|
||||
const handleAddToPlaylist = useCallback(async (playlistId: string) => {
|
||||
if (!selectedTrackId) return;
|
||||
onAddToPlaylist(playlistId, selectedTrackId);
|
||||
setShowPlaylistSelector(false);
|
||||
setSelectedTrackId(null);
|
||||
}, [selectedTrackId, onAddToPlaylist]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<GradientSpinner size="md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tracks.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<AudioLines className="w-12 h-12" />}
|
||||
title="No songs yet"
|
||||
description="Your library is empty. Sync your music to get started."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header row */}
|
||||
<div className="grid grid-cols-[auto_1fr_auto] md:grid-cols-[auto_1fr_1fr_auto] items-center gap-3 px-3 py-2 border-b border-white/10 text-xs text-gray-500 uppercase tracking-wider">
|
||||
<div className="w-8 text-center">#</div>
|
||||
<div>Title</div>
|
||||
<div className="hidden md:block">Album</div>
|
||||
<div className="w-[140px] text-right pr-2">Duration</div>
|
||||
</div>
|
||||
|
||||
<div data-tv-section="library-tracks" className="space-y-0.5">
|
||||
{tracks.map((track, index) => {
|
||||
const isCurrentlyPlaying = currentTrackId === track.id;
|
||||
return (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
index={index}
|
||||
isCurrentlyPlaying={isCurrentlyPlaying}
|
||||
onPlayTrack={() => onPlay(tracks, index)}
|
||||
onAddToQueue={onAddToQueue}
|
||||
onShowAddToPlaylist={handleShowAddToPlaylist}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<PlaylistSelector
|
||||
isOpen={showPlaylistSelector}
|
||||
onClose={() => {
|
||||
setShowPlaylistSelector(false);
|
||||
setSelectedTrackId(null);
|
||||
}}
|
||||
onSelectPlaylist={handleAddToPlaylist}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
146
frontend/features/library/hooks/useLibraryActions.ts
Normal file
146
frontend/features/library/hooks/useLibraryActions.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { api } from "@/lib/api";
|
||||
import { useAudio } from "@/lib/audio-context";
|
||||
import { Track } from "../types";
|
||||
|
||||
// Helper to convert library Track to audio context Track format
|
||||
const formatTrackForAudio = (track: Track) => ({
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
duration: track.duration,
|
||||
artist: {
|
||||
id: track.album?.artist?.id,
|
||||
name: track.album?.artist?.name || "Unknown Artist",
|
||||
},
|
||||
album: {
|
||||
id: track.album?.id,
|
||||
title: track.album?.title || "Unknown Album",
|
||||
coverArt: track.album?.coverArt,
|
||||
},
|
||||
});
|
||||
|
||||
export function useLibraryActions() {
|
||||
const { playTrack, playTracks, addToQueue } = useAudio();
|
||||
|
||||
const playArtist = async (artistId: string) => {
|
||||
try {
|
||||
const albumsData = await api.getAlbums({ artistId });
|
||||
if (!albumsData.albums || albumsData.albums.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstAlbum = await api.getAlbum(albumsData.albums[0].id);
|
||||
if (
|
||||
!firstAlbum ||
|
||||
!firstAlbum.tracks ||
|
||||
firstAlbum.tracks.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tracksWithAlbum = firstAlbum.tracks.map((track: any) => ({
|
||||
...track,
|
||||
album: {
|
||||
id: firstAlbum.id,
|
||||
title: firstAlbum.title,
|
||||
coverArt: firstAlbum.coverArt || firstAlbum.coverUrl,
|
||||
},
|
||||
artist: {
|
||||
id: firstAlbum.artist?.id,
|
||||
name: firstAlbum.artist?.name,
|
||||
},
|
||||
}));
|
||||
|
||||
playTracks(tracksWithAlbum, 0);
|
||||
} catch (error) {
|
||||
console.error("Error playing artist:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const playAlbum = async (albumId: string) => {
|
||||
try {
|
||||
const album = await api.getAlbum(albumId);
|
||||
if (!album || !album.tracks || album.tracks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tracksWithAlbum = album.tracks.map((track: any) => ({
|
||||
...track,
|
||||
album: {
|
||||
id: album.id,
|
||||
title: album.title,
|
||||
coverArt: album.coverArt || album.coverUrl,
|
||||
},
|
||||
artist: {
|
||||
id: album.artist?.id,
|
||||
name: album.artist?.name,
|
||||
},
|
||||
}));
|
||||
|
||||
playTracks(tracksWithAlbum, 0);
|
||||
} catch (error) {
|
||||
console.error("Error playing album:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const playTrackAction = (track: Track) => {
|
||||
try {
|
||||
playTrack(formatTrackForAudio(track));
|
||||
} catch (error) {
|
||||
console.error("Error playing track:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const addTrackToQueue = (track: Track) => {
|
||||
try {
|
||||
addToQueue(formatTrackForAudio(track));
|
||||
} catch (error) {
|
||||
console.error("Error adding track to queue:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const addTrackToPlaylist = async (playlistId: string, trackId: string) => {
|
||||
try {
|
||||
await api.addTrackToPlaylist(playlistId, trackId);
|
||||
} catch (error) {
|
||||
console.error("Error adding track to playlist:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTrack = async (id: string): Promise<void> => {
|
||||
try {
|
||||
await api.deleteTrack(id);
|
||||
} catch (error) {
|
||||
console.error("Error deleting track:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAlbum = async (id: string): Promise<void> => {
|
||||
try {
|
||||
await api.deleteAlbum(id);
|
||||
} catch (error) {
|
||||
console.error("Error deleting album:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteArtist = async (id: string): Promise<void> => {
|
||||
try {
|
||||
await api.deleteArtist(id);
|
||||
} catch (error) {
|
||||
console.error("Error deleting artist:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
playArtist,
|
||||
playAlbum,
|
||||
playTrack: playTrackAction,
|
||||
addTrackToQueue,
|
||||
addTrackToPlaylist,
|
||||
deleteTrack,
|
||||
deleteAlbum,
|
||||
deleteArtist,
|
||||
};
|
||||
}
|
||||
64
frontend/features/library/hooks/useLibraryData.ts
Normal file
64
frontend/features/library/hooks/useLibraryData.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Artist, Album, Track, Tab } from "../types";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
export type LibraryFilter = "owned" | "discovery" | "all";
|
||||
|
||||
interface UseLibraryDataProps {
|
||||
activeTab: Tab;
|
||||
filter?: LibraryFilter;
|
||||
}
|
||||
|
||||
export function useLibraryData({
|
||||
activeTab,
|
||||
filter = "owned",
|
||||
}: UseLibraryDataProps) {
|
||||
const [artists, setArtists] = useState<Artist[]>([]);
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [tracks, setTracks] = useState<Track[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (activeTab === "artists") {
|
||||
const { artists } = await api.getArtists({
|
||||
limit: 500,
|
||||
filter,
|
||||
});
|
||||
setArtists(artists);
|
||||
} else if (activeTab === "albums") {
|
||||
const { albums } = await api.getAlbums({ limit: 500, filter });
|
||||
setAlbums(albums);
|
||||
} else if (activeTab === "tracks") {
|
||||
// Tracks filter could be added later if needed
|
||||
const { tracks } = await api.getTracks({ limit: 500 });
|
||||
setTracks(tracks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load library data:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [activeTab, filter, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const reloadData = () => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
return {
|
||||
artists,
|
||||
albums,
|
||||
tracks,
|
||||
isLoading,
|
||||
reloadData,
|
||||
};
|
||||
}
|
||||
45
frontend/features/library/types.ts
Normal file
45
frontend/features/library/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export type Tab = "artists" | "albums" | "tracks";
|
||||
|
||||
export interface Artist {
|
||||
id: string;
|
||||
mbid?: string;
|
||||
name: string;
|
||||
coverArt?: string;
|
||||
albumCount?: number;
|
||||
trackCount?: number;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
id: string;
|
||||
title: string;
|
||||
coverArt?: string;
|
||||
year?: number;
|
||||
artist?: {
|
||||
id: string;
|
||||
mbid?: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
title: string;
|
||||
duration: number;
|
||||
trackNumber?: number;
|
||||
album?: {
|
||||
id: string;
|
||||
title: string;
|
||||
coverArt?: string;
|
||||
artist?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeleteDialogState {
|
||||
isOpen: boolean;
|
||||
type: "track" | "album" | "artist";
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
Reference in New Issue
Block a user